diff --git a/class-match/build.gradle.kts b/class-match/build.gradle.kts index f1c9abb..a99f31a 100644 --- a/class-match/build.gradle.kts +++ b/class-match/build.gradle.kts @@ -4,6 +4,7 @@ plugins { dependencies { implementation(project(":utils")) + testImplementation(libs.asm) sampleBytecode(libs.asm.test) sampleBytecode("org.springframework:spring-web:7.0.9") diff --git a/class-match/src/main/java/datadog/instrument/classmatch/MethodMatcher.java b/class-match/src/main/java/datadog/instrument/classmatch/MethodMatcher.java index 7235bd0..225c58f 100644 --- a/class-match/src/main/java/datadog/instrument/classmatch/MethodMatcher.java +++ b/class-match/src/main/java/datadog/instrument/classmatch/MethodMatcher.java @@ -186,8 +186,8 @@ default MethodMatcher parameter(int paramIndex, TypeMatcher typeMatcher) { * @return matcher of methods returning the same type */ default MethodMatcher returning(String returnType) { - String returnDescriptor = descriptor(returnType); - return and(m -> m.descriptor.endsWith(returnDescriptor)); + String returnSuffix = ')' + descriptor(returnType); + return and(m -> m.descriptor.endsWith(returnSuffix)); } /** @@ -197,8 +197,8 @@ default MethodMatcher returning(String returnType) { * @return matcher of methods returning the same type */ default MethodMatcher returning(Class returnType) { - String returnDescriptor = descriptor(returnType); - return and(m -> m.descriptor.endsWith(returnDescriptor)); + String returnSuffix = ')' + descriptor(returnType); + return and(m -> m.descriptor.endsWith(returnSuffix)); } /** diff --git a/class-match/src/test/java/datadog/instrument/classmatch/AccessMatcherTest.java b/class-match/src/test/java/datadog/instrument/classmatch/AccessMatcherTest.java new file mode 100644 index 0000000..e335279 --- /dev/null +++ b/class-match/src/test/java/datadog/instrument/classmatch/AccessMatcherTest.java @@ -0,0 +1,77 @@ +package datadog.instrument.classmatch; + +import static datadog.instrument.classmatch.AccessMatcher.ABSTRACT; +import static datadog.instrument.classmatch.AccessMatcher.CLASS; +import static datadog.instrument.classmatch.AccessMatcher.CONCRETE; +import static datadog.instrument.classmatch.AccessMatcher.FINAL; +import static datadog.instrument.classmatch.AccessMatcher.INSTANCE; +import static datadog.instrument.classmatch.AccessMatcher.INTERFACE; +import static datadog.instrument.classmatch.AccessMatcher.NON_FINAL; +import static datadog.instrument.classmatch.AccessMatcher.PACKAGE_PRIVATE; +import static datadog.instrument.classmatch.AccessMatcher.PRIVATE; +import static datadog.instrument.classmatch.AccessMatcher.PROTECTED; +import static datadog.instrument.classmatch.AccessMatcher.PUBLIC; +import static datadog.instrument.classmatch.AccessMatcher.STATIC; +import static datadog.instrument.classmatch.AccessMatcher.SYNCHRONIZED; +import static datadog.instrument.classmatch.AccessMatcher.TRANSIENT; +import static datadog.instrument.classmatch.AccessMatcher.VARARGS; +import static datadog.instrument.classmatch.AccessMatcher.VOLATILE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class AccessMatcherTest { + static Stream flags() { + return Stream.of( + Arguments.of("public", PUBLIC, 0x0001, false), + Arguments.of("private", PRIVATE, 0x0002, false), + Arguments.of("protected", PROTECTED, 0x0004, false), + Arguments.of("package-private", PACKAGE_PRIVATE, 0x0007, true), + Arguments.of("static", STATIC, 0x0008, false), + Arguments.of("instance", INSTANCE, 0x0008, true), + Arguments.of("final", FINAL, 0x0010, false), + Arguments.of("non-final", NON_FINAL, 0x0010, true), + Arguments.of("synchronized", SYNCHRONIZED, 0x0020, false), + Arguments.of("volatile", VOLATILE, 0x0040, false), + Arguments.of("varargs", VARARGS, 0x0080, false), + Arguments.of("transient", TRANSIENT, 0x0080, false), + Arguments.of("interface", INTERFACE, 0x0200, false), + Arguments.of("class", CLASS, 0x0200, true), + Arguments.of("abstract", ABSTRACT, 0x0400, false), + Arguments.of("concrete", CONCRETE, 0x0400, true)); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("flags") + void flagsIgnoreUnrelatedBits(String name, AccessMatcher matcher, int flag, boolean inverted) { + // Exercise every combination of the JVM's 16 access bits, including synthetic/bridge bits. + for (int access = 0; access <= 0xffff; access++) { + boolean expected = ((access & flag) != 0) != inverted; + if (matcher.test(access) != expected) { + fail(name + ": expected " + expected + " for access " + access); + } + } + } + + @Test + void compositionAndNegation() { + for (int access = 0; access < 32; access++) { + assertEquals(PUBLIC.test(access) && STATIC.test(access), PUBLIC.and(STATIC).test(access)); + assertEquals(PUBLIC.test(access) || PRIVATE.test(access), PUBLIC.or(PRIVATE).test(access)); + assertEquals(!PUBLIC.test(access), StandardMatchers.not(PUBLIC).test(access)); + } + AccessMatcher unexpected = + access -> { + throw new AssertionError("should short circuit"); + }; + assertFalse(PUBLIC.and(unexpected).test(0)); + assertTrue(PUBLIC.or(unexpected).test(1)); + } +} diff --git a/class-match/src/test/java/datadog/instrument/classmatch/AnnotationRegistrationProbe.java b/class-match/src/test/java/datadog/instrument/classmatch/AnnotationRegistrationProbe.java new file mode 100644 index 0000000..ec04191 --- /dev/null +++ b/class-match/src/test/java/datadog/instrument/classmatch/AnnotationRegistrationProbe.java @@ -0,0 +1,41 @@ +package datadog.instrument.classmatch; + +import static datadog.instrument.classmatch.BytecodeTestSupport.annotatedClass; + +import java.util.Arrays; +import java.util.Collections; + +/** Entry point for testing initial static state in a separate JVM, independent of JUnit order. */ +public final class AnnotationRegistrationProbe { + private AnnotationRegistrationProbe() {} + + public static void main(String[] args) { + byte[] bytes = annotatedClass("tests/First", "tests/Second"); + expect(bytes); // no registration has occurred in this JVM + if ("single".equals(args[0])) { + ClassFile.annotationOfInterest("tests/First"); + } else { + ClassFile.annotationsOfInterest(Collections.singletonList("tests/First")); + } + expect(bytes, "tests/First"); + // Both registration APIs must preserve earlier names and tolerate repeated registration. + ClassFile.annotationsOfInterest(Arrays.asList("tests/First", "tests/Second")); + ClassFile.annotationOfInterest("tests/First"); + ClassFile.annotationsOfInterest(Collections.emptyList()); + expect(bytes, "tests/First", "tests/Second"); + } + + private static void expect(byte[] bytes, String... expected) { + ClassOutline outline = ClassFile.outline(bytes); + if (!Arrays.equals(expected, outline.annotations) + || !Arrays.equals(expected, outline.methods[0].annotations)) { + throw new AssertionError( + "Expected " + + Arrays.toString(expected) + + ", class=" + + Arrays.toString(outline.annotations) + + ", method=" + + Arrays.toString(outline.methods[0].annotations)); + } + } +} diff --git a/class-match/src/test/java/datadog/instrument/classmatch/AnnotationRegistrationTest.java b/class-match/src/test/java/datadog/instrument/classmatch/AnnotationRegistrationTest.java new file mode 100644 index 0000000..e4c537b --- /dev/null +++ b/class-match/src/test/java/datadog/instrument/classmatch/AnnotationRegistrationTest.java @@ -0,0 +1,109 @@ +package datadog.instrument.classmatch; + +import static datadog.instrument.classmatch.BytecodeTestSupport.annotatedClass; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class AnnotationRegistrationTest { + @TempDir Path temporary; + + @ParameterizedTest + @ValueSource(strings = {"single", "collection"}) + void firstRegistrationWorksInAFreshJvm(String mode) throws Exception { + Path output = temporary.resolve("probe-" + mode + ".log"); + Process process = + new ProcessBuilder( + Paths.get(System.getProperty("java.home"), "bin", "java").toString(), + "-cp", + System.getProperty("java.class.path"), + AnnotationRegistrationProbe.class.getName(), + mode) + .redirectErrorStream(true) + .redirectOutput(output.toFile()) + .start(); + try { + assertTrue(process.waitFor(20, TimeUnit.SECONDS), "annotation probe timed out"); + assertEquals( + 0, process.exitValue(), new String(Files.readAllBytes(output), StandardCharsets.UTF_8)); + } finally { + process.destroyForcibly(); + process.waitFor(5, TimeUnit.SECONDS); + } + } + + @Test + void concurrentRegistrationPublishesCompleteSnapshotsWithoutLosingNames() throws Exception { + int writers = 4, readers = 2, registrations = 12; + List names = new ArrayList<>(); + for (int i = 0; i < writers * registrations; i++) { + names.add("tests/Concurrent/Marker" + i); + } + byte[] bytes = annotatedClass(names.toArray(new String[0])); + Set allowed = new HashSet<>(names); + CountDownLatch start = new CountDownLatch(1); + ExecutorService pool = Executors.newFixedThreadPool(writers + readers); + List> tasks = new ArrayList<>(); + try { + for (int worker = 0; worker < writers + readers; worker++) { + final int id = worker; + tasks.add( + pool.submit( + () -> { + start.await(); + Set registered = new HashSet<>(); + for (int i = 0; i < registrations; i++) { + if (id < writers) { + String name = names.get(id * registrations + i); + if ((id & 1) == 0) { + ClassFile.annotationOfInterest(name); + } else { + ClassFile.annotationsOfInterest(Arrays.asList(name, name)); + } + registered.add(name); + } + assertSnapshot(bytes, registered, allowed); + } + return null; + })); + } + start.countDown(); + for (Future task : tasks) { + task.get(30, TimeUnit.SECONDS); + } + assertSnapshot(bytes, allowed, allowed); + } finally { + pool.shutdownNow(); + assertTrue(pool.awaitTermination(15, TimeUnit.SECONDS), "annotation workers did not stop"); + } + } + + private static void assertSnapshot(byte[] bytes, Set required, Set allowed) { + ClassOutline outline = ClassFile.outline(bytes); + for (String[] annotations : + new String[][] {outline.annotations, outline.methods[0].annotations}) { + Set actual = new HashSet<>(Arrays.asList(annotations)); + assertEquals(annotations.length, actual.size(), "snapshot contains duplicate names"); + assertTrue(actual.containsAll(required), "snapshot lost registered names"); + assertTrue(allowed.containsAll(actual), "snapshot contains unexpected names"); + } + } +} diff --git a/class-match/src/test/java/datadog/instrument/classmatch/BytecodeTestSupport.java b/class-match/src/test/java/datadog/instrument/classmatch/BytecodeTestSupport.java new file mode 100644 index 0000000..6787846 --- /dev/null +++ b/class-match/src/test/java/datadog/instrument/classmatch/BytecodeTestSupport.java @@ -0,0 +1,32 @@ +package datadog.instrument.classmatch; + +import static org.objectweb.asm.Opcodes.ACC_ABSTRACT; +import static org.objectweb.asm.Opcodes.ACC_PUBLIC; +import static org.objectweb.asm.Opcodes.V1_8; + +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.MethodVisitor; + +/** ASM fixtures are independent of the parser and descriptor conversion under test. */ +final class BytecodeTestSupport { + private BytecodeTestSupport() {} + + static ClassWriter classWriter(String name) { + ClassWriter writer = new ClassWriter(0); + writer.visit(V1_8, ACC_PUBLIC | ACC_ABSTRACT, name, null, "java/lang/Object", null); + return writer; + } + + static byte[] annotatedClass(String... names) { + ClassWriter writer = classWriter("tests/Annotated"); + MethodVisitor method = + writer.visitMethod(ACC_PUBLIC | ACC_ABSTRACT, "marked", "()V", null, null); + for (String name : names) { + writer.visitAnnotation("L" + name + ";", true).visitEnd(); + method.visitAnnotation("L" + name + ";", true).visitEnd(); + } + method.visitEnd(); + writer.visitEnd(); + return writer.toByteArray(); + } +} diff --git a/class-match/src/test/java/datadog/instrument/classmatch/ClassFileByteKeyTest.java b/class-match/src/test/java/datadog/instrument/classmatch/ClassFileByteKeyTest.java new file mode 100644 index 0000000..a06f73b --- /dev/null +++ b/class-match/src/test/java/datadog/instrument/classmatch/ClassFileByteKeyTest.java @@ -0,0 +1,45 @@ +package datadog.instrument.classmatch; + +import static java.nio.charset.StandardCharsets.US_ASCII; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ClassFileByteKeyTest { + @Test + void byteRangesUseTheirContentAndIgnoreSurroundingBytes() { + byte[] value = {0, -1, 127, -128}; + byte[] surrounded = {42, 0, -1, 127, -128, 99}; + ClassFile.UtfKey whole = new ClassFile.UtfKey(value); + ClassFile.UtfKey window = new ClassFile.UtfKey(surrounded, 1, value.length); + assertEquals(Arrays.hashCode(value), whole.hashCode()); + assertEquals(whole.hashCode(), window.hashCode()); + assertEquals(whole, window); + assertEquals(window, whole); + assertTrue(ClassFile.sameBytes(surrounded, 1, 5, value, 0)); + assertEquals(Arrays.hashCode(value), ClassFile.hashBytes(surrounded, 1, 5)); + assertEquals(1, ClassFile.hashBytes(value, 2, 2)); + assertTrue(ClassFile.sameBytes(value, 2, 2, new byte[0], 0)); + assertFalse(whole.equals(null)); + assertFalse(whole.equals(new byte[] {0, -1, 127, -128})); + assertFalse(whole.equals(new ClassFile.UtfKey(new byte[0]))); + } + + @Test + void distinctBytesWithTheSameHashRemainDifferentKeys() { + ClassFile.UtfKey first = new ClassFile.UtfKey("Aa".getBytes(US_ASCII)); + ClassFile.UtfKey second = new ClassFile.UtfKey("BB".getBytes(US_ASCII)); + assertEquals(first.hashCode(), second.hashCode()); + assertFalse(first.equals(second)); + Map lookup = new HashMap<>(); + lookup.put(first, "first"); + lookup.put(second, "second"); + assertEquals("first", lookup.get(new ClassFile.UtfKey("!Aa!".getBytes(US_ASCII), 1, 2))); + assertEquals("second", lookup.get(new ClassFile.UtfKey("!BB!".getBytes(US_ASCII), 1, 2))); + } +} diff --git a/class-match/src/test/java/datadog/instrument/classmatch/ClassFileUnicodeTest.java b/class-match/src/test/java/datadog/instrument/classmatch/ClassFileUnicodeTest.java new file mode 100644 index 0000000..c77ce23 --- /dev/null +++ b/class-match/src/test/java/datadog/instrument/classmatch/ClassFileUnicodeTest.java @@ -0,0 +1,51 @@ +package datadog.instrument.classmatch; + +import static datadog.instrument.classmatch.BytecodeTestSupport.classWriter; +import static datadog.instrument.classmatch.FieldMatcher.field; +import static datadog.instrument.classmatch.MatcherTestSupport.assertMatches; +import static datadog.instrument.classmatch.MethodMatcher.method; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.objectweb.asm.Opcodes.ACC_ABSTRACT; +import static org.objectweb.asm.Opcodes.ACC_PUBLIC; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.objectweb.asm.ClassWriter; + +class ClassFileUnicodeTest { + @ParameterizedTest + @ValueSource(strings = {"Café", "Grüße", "例クラス", "\uD801\uDC00", "Asciié例\uD801\uDC00End"}) + void modifiedUtf8NamesRoundTripAtEveryPositionAndAtAnOffset(String name) { + String className = "tests/" + name; + String descriptor = "L" + className + ";"; + ClassWriter writer = classWriter(className); + writer.visitField(ACC_PUBLIC, "field" + name, descriptor, null, null).visitEnd(); + writer + .visitMethod( + ACC_PUBLIC | ACC_ABSTRACT, + "method" + name, + "(" + descriptor + "I)" + descriptor, + null, + null) + .visitEnd(); + writer.visitEnd(); + byte[] bytes = writer.toByteArray(); + for (int offset : new int[] {0, 29}) { + byte[] input = new byte[bytes.length + offset]; + System.arraycopy(bytes, 0, input, offset, bytes.length); + ClassHeader header = ClassFile.header(input, offset); + ClassOutline outline = ClassFile.outline(input, offset); + assertEquals(className, header.className); + assertEquals(className, outline.className); + assertEquals("field" + name, outline.fields[0].fieldName); + assertEquals(descriptor, outline.fields[0].descriptor); + assertEquals("method" + name, outline.methods[0].methodName); + assertEquals("(" + descriptor + "I)" + descriptor, outline.methods[0].descriptor); + assertMatches(field("field" + name).type(className), outline.fields[0], true); + assertMatches( + method("method" + name).parameters(className, "int").returning(className), + outline.methods[0], + true); + } + } +} diff --git a/class-match/src/test/java/datadog/instrument/classmatch/ClassMatcherTest.java b/class-match/src/test/java/datadog/instrument/classmatch/ClassMatcherTest.java new file mode 100644 index 0000000..357bbe9 --- /dev/null +++ b/class-match/src/test/java/datadog/instrument/classmatch/ClassMatcherTest.java @@ -0,0 +1,327 @@ +package datadog.instrument.classmatch; + +import static datadog.instrument.classmatch.AccessMatcher.ABSTRACT; +import static datadog.instrument.classmatch.AccessMatcher.FINAL; +import static datadog.instrument.classmatch.AccessMatcher.INTERFACE; +import static datadog.instrument.classmatch.AccessMatcher.PRIVATE; +import static datadog.instrument.classmatch.AccessMatcher.PROTECTED; +import static datadog.instrument.classmatch.AccessMatcher.PUBLIC; +import static datadog.instrument.classmatch.AccessMatcher.STATIC; +import static datadog.instrument.classmatch.AccessMatcher.SYNCHRONIZED; +import static datadog.instrument.classmatch.AccessMatcher.TRANSIENT; +import static datadog.instrument.classmatch.AccessMatcher.VARARGS; +import static datadog.instrument.classmatch.AccessMatcher.VOLATILE; +import static datadog.instrument.classmatch.ClassMatcher.annotatedWith; +import static datadog.instrument.classmatch.ClassMatcher.declares; +import static datadog.instrument.classmatch.FieldMatcher.field; +import static datadog.instrument.classmatch.MatcherTestSupport.assertMatches; +import static datadog.instrument.classmatch.MatcherTestSupport.classOutline; +import static datadog.instrument.classmatch.MatcherTestSupport.emptyClass; +import static datadog.instrument.classmatch.MatcherTestSupport.fieldOutline; +import static datadog.instrument.classmatch.MatcherTestSupport.methodOutline; +import static datadog.instrument.classmatch.MatcherTestSupport.parse; +import static datadog.instrument.classmatch.MethodMatcher.constructor; +import static datadog.instrument.classmatch.MethodMatcher.method; +import static datadog.instrument.classmatch.MethodMatcher.staticInitializer; +import static datadog.instrument.classmatch.StandardMatchers.not; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +class ClassMatcherTest { + @Test + void emptyClassNeverInvokesMemberMatchers() { + FieldMatcher unexpectedField = + f -> { + throw new AssertionError("no fields"); + }; + MethodMatcher unexpectedMethod = + m -> { + throw new AssertionError("no methods"); + }; + assertMatches(declares(unexpectedField), emptyClass(), false); + assertMatches(declares(unexpectedMethod), emptyClass(), false); + assertMatches(declares(PUBLIC, unexpectedField), emptyClass(), false); + assertMatches(declares(PUBLIC, unexpectedMethod), emptyClass(), false); + } + + @Test + void declarationsSearchPastNonmatchingMembersAndShortCircuit() { + FieldOutline[] fields = { + fieldOutline(0, "other", "I"), fieldOutline(Modifier.PUBLIC, "value", "I"), null + }; + MethodOutline[] methods = { + methodOutline(0, "other", "()V"), methodOutline(Modifier.PUBLIC, "execute", "()V"), null + }; + ClassOutline candidate = classOutline(fields, methods); + assertMatches(declares(field("value")), candidate, true); + assertMatches(declares(PUBLIC, field("value")), candidate, true); + assertMatches(declares(method("execute")), candidate, true); + assertMatches(declares(PUBLIC, method("execute")), candidate, true); + } + + @Test + void accessAndSignatureMustMatchTheSameMember() { + ClassOutline candidate = + classOutline( + new FieldOutline[] { + fieldOutline(Modifier.PRIVATE, "value", "I"), + fieldOutline(Modifier.PUBLIC, "other", "I") + }, + new MethodOutline[] { + methodOutline(Modifier.PRIVATE, "execute", "()V"), + methodOutline(Modifier.PUBLIC, "other", "()V") + }); + assertMatches(declares(PUBLIC, field("value")), candidate, false); + assertMatches(declares(PRIVATE, field("value")), candidate, true); + assertMatches(declares(PUBLIC, method("execute")), candidate, false); + assertMatches(declares(PRIVATE, method("execute")), candidate, true); + assertMatches(declares(field("missing")), candidate, false); + assertMatches(declares(method("missing")), candidate, false); + } + + @Test + void annotationOverloadsNormalizeNamesAndHandleNoCandidates() { + ClassOutline candidate = + classOutline(new FieldOutline[0], new MethodOutline[0], "sample/Other", "sample/Marker"); + assertMatches(annotatedWith("sample.Marker"), candidate, true); + assertMatches(annotatedWith("sample/Marker"), candidate, true); + assertMatches(annotatedWith("sample.Missing", "sample.Marker"), candidate, true); + assertMatches(annotatedWith(Arrays.asList("sample.Missing", "sample.Marker")), candidate, true); + assertMatches(annotatedWith("sample.Missing"), candidate, false); + assertMatches(annotatedWith(new String[0]), candidate, false); + assertMatches(annotatedWith(Collections.emptyList()), candidate, false); + assertMatches(annotatedWith("sample.Marker"), emptyClass(), false); + } + + @Test + void fluentMatchersWorkWithParsedBytecode() { + // Construct annotation matchers before parsing: they register annotations of interest. + ClassMatcher matcher = + annotatedWith(RuntimeMarker.class.getName()) + .and(declares(PUBLIC.and(STATIC).and(FINAL), field("CONSTANT").type(long.class))) + .and(declares(PRIVATE.and(VOLATILE), field("state").type(String.class))) + .and(declares(PROTECTED.and(TRANSIENT), field("values").type(int[][].class))) + .and( + declares( + PUBLIC.and(SYNCHRONIZED), + method("execute") + .parameters(String.class, int.class) + .returning(String.class) + .annotatedWith(RuntimeMarker.class.getName()))) + .and(declares(VARARGS, method("collect").parameters(String[].class))) + .and(declares(constructor().noParameters())) + .and(declares(STATIC, staticInitializer())) + .and(not(declares(field("missing")))); + ClassOutline outline = parse(Fixture.class); + assertMatches(matcher, outline, true); + assertEquals(Fixture.class.getName().replace('.', '/'), outline.className); + assertEquals(Parent.class.getName().replace('.', '/'), outline.superName); + assertArrayEquals(new String[] {"java/lang/Runnable"}, outline.interfaces); + assertTrue(PUBLIC.test(outline.access)); + } + + @Test + void declarationsAndAnnotationsDoNotSearchParents() { + ClassMatcher inheritedAnnotation = annotatedWith(ParentMarker.class.getName()); + ClassOutline outline = parse(Fixture.class); + assertMatches(declares(field("inheritedField")), outline, false); + assertMatches(declares(method("inheritedMethod")), outline, false); + assertMatches(inheritedAnnotation, outline, false); + ClassOutline parent = parse(Parent.class); + assertMatches(declares(field("inheritedField")), parent, true); + assertMatches(declares(method("inheritedMethod")), parent, true); + assertMatches(inheritedAnnotation, parent, true); + } + + @Test + void onlyRegisteredRuntimeAnnotationsAreExtracted() { + ClassMatcher marker = annotatedWith(RuntimeMarker.class.getName()); + // CLASS retention must remain invisible even when explicitly registered. + ClassMatcher invisible = annotatedWith(ClassMarker.class.getName()); + MethodMatcher methodMarker = method("execute").annotatedWith(RuntimeMarker.class.getName()); + ClassOutline outline = parse(Fixture.class); + String markerName = RuntimeMarker.class.getName().replace('.', '/'); + // The unregistered runtime annotation contains every annotation element shape, ensuring + // skipping its values still finds the interesting annotation that follows it. + assertArrayEquals(new String[] {markerName}, outline.annotations); + assertMatches(marker, outline, true); + assertMatches(invisible, outline, false); + assertMatches(declares(methodMarker), outline, true); + MethodOutline execute = + Arrays.stream(outline.methods) + .filter(m -> m.methodName.equals("execute")) + .findFirst() + .get(); + assertArrayEquals(new String[] {markerName}, execute.annotations); + } + + @Test + void annotationFactoryOverloadsRegisterInterestForParsing() { + ClassMatcher varargs = annotatedWith("missing.Annotation", VarargsMarker.class.getName()); + ClassMatcher collection = + annotatedWith(Collections.singletonList(CollectionMarker.class.getName())); + MethodMatcher methodVarargs = + method("marked").annotatedWith("missing.Annotation", VarargsMarker.class.getName()); + MethodMatcher methodCollection = + method("marked").annotatedWith(Collections.singletonList(CollectionMarker.class.getName())); + ClassOutline outline = parse(MultipleAnnotations.class); + assertMatches( + varargs.and(collection).and(declares(methodVarargs.and(methodCollection))), outline, true); + } + + @Test + void bridgeMethodsAndInterfacesArePreserved() { + ClassOutline implementation = parse(StringSupplier.class); + assertMatches( + declares(method("get").noParameters().returning(String.class)), implementation, true); + assertMatches( + declares(method("get").noParameters().returning(Object.class)), implementation, true); + assertEquals( + 2, Arrays.stream(implementation.methods).filter(m -> m.methodName.equals("get")).count()); + ClassOutline iface = parse(Supplier.class); + assertTrue(INTERFACE.and(ABSTRACT).test(iface.access)); + assertEquals("java/lang/Object", iface.superName); + assertMatches( + declares(PUBLIC.and(ABSTRACT), method("get").returning(Object.class)), iface, true); + assertMatches(declares(constructor()), iface, false); + } + + @Retention(RetentionPolicy.RUNTIME) + @interface RuntimeMarker {} + + @Retention(RetentionPolicy.CLASS) + @interface ClassMarker {} + + @Retention(RetentionPolicy.RUNTIME) + @Inherited + @interface ParentMarker {} + + @Retention(RetentionPolicy.RUNTIME) + @interface VarargsMarker {} + + @Retention(RetentionPolicy.RUNTIME) + @interface CollectionMarker {} + + enum Choice { + ONE + } + + @interface Nested { + String value(); + } + + @Retention(RetentionPolicy.RUNTIME) + @interface Unregistered { + byte b(); + + char c(); + + double d(); + + float f(); + + int i(); + + long l(); + + short s(); + + boolean z(); + + String text(); + + Class type(); + + Choice choice(); + + Nested nested(); + + int[] array(); + } + + @ParentMarker + public static class Parent { + public int inheritedField; + + public void inheritedMethod() {} + } + + @Unregistered( + b = 1, + c = 'x', + d = 1.5, + f = 2.5f, + i = 3, + l = 4L, + s = 5, + z = true, + text = "hello", + type = String.class, + choice = Choice.ONE, + nested = @Nested("nested"), + array = {1, 2}) + @RuntimeMarker + @ClassMarker + public static class Fixture extends Parent implements Runnable { + public static final long CONSTANT = 1234567890123L; + public static final double DOUBLE = 1.25; + private volatile String state; + protected transient int[][] values; + private static Object initialized; + + static { + initialized = new Object(); + } + + @Unregistered( + b = 1, + c = 'x', + d = 1.5, + f = 2.5f, + i = 3, + l = 4L, + s = 5, + z = true, + text = "hello", + type = String.class, + choice = Choice.ONE, + nested = @Nested("nested"), + array = {1, 2}) + @RuntimeMarker + @ClassMarker + public synchronized String execute(String value, int count) { + return value; + } + + public void collect(String... values) {} + + public void run() {} + } + + @VarargsMarker + @CollectionMarker + static class MultipleAnnotations { + @VarargsMarker + @CollectionMarker + void marked() {} + } + + interface Supplier { + T get(); + } + + static class StringSupplier implements Supplier { + public String get() { + return "value"; + } + } +} diff --git a/class-match/src/test/java/datadog/instrument/classmatch/FieldMatcherTest.java b/class-match/src/test/java/datadog/instrument/classmatch/FieldMatcherTest.java new file mode 100644 index 0000000..59fc442 --- /dev/null +++ b/class-match/src/test/java/datadog/instrument/classmatch/FieldMatcherTest.java @@ -0,0 +1,79 @@ +package datadog.instrument.classmatch; + +import static datadog.instrument.classmatch.AccessMatcher.PRIVATE; +import static datadog.instrument.classmatch.AccessMatcher.PUBLIC; +import static datadog.instrument.classmatch.AccessMatcher.STATIC; +import static datadog.instrument.classmatch.FieldMatcher.field; +import static datadog.instrument.classmatch.MatcherTestSupport.assertMatches; +import static datadog.instrument.classmatch.MatcherTestSupport.fieldOutline; +import static datadog.instrument.classmatch.StandardMatchers.nameEndsWith; +import static datadog.instrument.classmatch.StandardMatchers.nameStartsWith; +import static datadog.instrument.classmatch.StandardMatchers.namedOneOf; +import static datadog.instrument.classmatch.TypeMatcher.type; +import static datadog.instrument.classmatch.TypeMatcher.typeEndsWith; +import static datadog.instrument.classmatch.TypeMatcher.typeOneOf; +import static datadog.instrument.classmatch.TypeMatcher.typeStartsWith; + +import java.lang.reflect.Modifier; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +class FieldMatcherTest { + @ParameterizedTest + @MethodSource("datadog.instrument.classmatch.MatcherTestSupport#types") + void exactTypeOverloads(String name, Class type, String descriptor) { + FieldOutline candidate = fieldOutline(descriptor); + assertMatches(field("value").type(name), candidate, true); + assertMatches(field("value").type(type), candidate, true); + assertMatches(field("missing").type(type), candidate, false); + assertMatches(field("missing").type(name), candidate, false); + assertMatches(field("value").type(Object.class), candidate, false); + assertMatches(field("value").type(name), fieldOutline("Ljava/lang/Object;"), false); + } + + @Test + void namesAccessAndTypeMustBelongToTheSameField() { + FieldOutline candidate = fieldOutline(Modifier.PUBLIC | Modifier.STATIC, "value", "I"); + assertMatches( + field(namedOneOf("missing", "value")).access(PUBLIC.and(STATIC)).type(int.class), + candidate, + true); + assertMatches(field(nameStartsWith("val").and(nameEndsWith("ue"))), candidate, true); + assertMatches(field("value").access(PRIVATE), candidate, false); + assertMatches(field("Value"), candidate, false); + } + + @Test + void objectTypePredicatesUseInternalNames() { + FieldOutline candidate = fieldOutline("Ljava/lang/String;"); + assertMatches(field("value").and(type("java.lang.String")), candidate, true); + assertMatches( + field("value").and(typeOneOf("java.lang.Object", "java.lang.String")), candidate, true); + assertMatches( + field("value").and(typeStartsWith("java.lang.").and(typeEndsWith("String"))), + candidate, + true); + assertMatches(field("value").and(type("java.lang.Object")), candidate, false); + assertMatches( + field("missing") + .and( + (TypeMatcher) + t -> { + throw new AssertionError("name should short circuit"); + }), + candidate, + false); + } + + @Test + void primitiveAndArrayTypesNeverReachTypePredicates() { + TypeMatcher unexpected = + t -> { + throw new AssertionError("only object names are supported"); + }; + for (String descriptor : new String[] {"I", "Z", "[I", "[[Ljava/lang/String;"}) { + assertMatches(field("value").and(unexpected), fieldOutline(descriptor), false); + } + } +} diff --git a/class-match/src/test/java/datadog/instrument/classmatch/GeneratedSignatureTest.java b/class-match/src/test/java/datadog/instrument/classmatch/GeneratedSignatureTest.java new file mode 100644 index 0000000..85ed1ef --- /dev/null +++ b/class-match/src/test/java/datadog/instrument/classmatch/GeneratedSignatureTest.java @@ -0,0 +1,138 @@ +package datadog.instrument.classmatch; + +import static datadog.instrument.classmatch.BytecodeTestSupport.classWriter; +import static datadog.instrument.classmatch.FieldMatcher.field; +import static datadog.instrument.classmatch.MatcherTestSupport.assertMatches; +import static datadog.instrument.classmatch.MethodMatcher.method; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.objectweb.asm.Opcodes.ACC_ABSTRACT; +import static org.objectweb.asm.Opcodes.ACC_PUBLIC; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Type; + +class GeneratedSignatureTest { + private static final Class[] TYPES = { + boolean.class, + byte.class, + char.class, + short.class, + int.class, + long.class, + float.class, + double.class, + Object.class, + String.class, + Map.Entry.class, + int[].class, + int[][].class, + String[].class, + String[][].class + }; + + @SuppressFBWarnings( + value = "DP_CREATE_CLASSLOADER_INSIDE_DO_PRIVILEGED", + justification = "Generated fixtures use an isolated test class loader") + @ParameterizedTest + @ValueSource(ints = {0, 1, 2}) + void generatedSignaturesAgreeWithReflection(int offset) throws Exception { + ClassWriter writer = classWriter("tests/GeneratedSignatures"); + Class[] returns = Arrays.copyOf(TYPES, TYPES.length + 1); + returns[TYPES.length] = void.class; + int count = 0; + for (Class type : TYPES) { + writer + .visitField(ACC_PUBLIC, "field" + count++, Type.getDescriptor(type), null, null) + .visitEnd(); + } + count = 0; + // Systematically cover every parameter/return pair, plus parameterless methods. + for (Class returnType : returns) { + addMethod(writer, count++, returnType); + for (Class paramType : TYPES) { + addMethod(writer, count++, returnType, paramType); + } + } + for (int i = 0; i < 64; i++) { + Class[] params = new Class[2 + i % 7]; + for (int p = 0; p < params.length; p++) { + params[p] = TYPES[(offset + i + p) % TYPES.length]; + } + addMethod(writer, count++, returns[(offset + i) % returns.length], params); + } + writer.visitEnd(); + byte[] bytecode = writer.toByteArray(); + Class loaded = new FixtureLoader().define(bytecode); + ClassOutline outline = ClassFile.outline(bytecode); + assertEquals(loaded.getName().replace('.', '/'), outline.className); + assertEquals(loaded.getSuperclass().getName().replace('.', '/'), outline.superName); + assertEquals(loaded.getModifiers(), outline.access); + assertEquals(loaded.getDeclaredFields().length, outline.fields.length); + assertEquals(loaded.getDeclaredMethods().length, outline.methods.length); + for (FieldOutline f : outline.fields) { + Class reflectedType = loaded.getDeclaredField(f.fieldName).getType(); + assertEquals(Type.getDescriptor(reflectedType), f.descriptor); + for (Class type : TYPES) { + assertMatches(field(f.fieldName).type(type), f, reflectedType == type); + assertMatches(field(f.fieldName).type(type.getTypeName()), f, reflectedType == type); + } + } + Map methods = new HashMap<>(); + for (Method m : loaded.getDeclaredMethods()) { + methods.put(m.getName(), m); + } + for (MethodOutline m : outline.methods) { + Method reflected = methods.remove(m.methodName); + assertEquals(Type.getMethodDescriptor(reflected), m.descriptor); + assertEquals(reflected.getModifiers(), m.access); + Class[] params = reflected.getParameterTypes(); + assertEquals(params.length, m.parameterCount()); + assertMatches(method(m.methodName).parameters(params), m, true); + assertMatches( + method().parameters(Arrays.stream(params).map(Class::getTypeName).toArray(String[]::new)), + m, + true); + assertMatches(method().parameters(params.length), m, true); + assertMatches(method().parameters(params.length + 1), m, false); + for (int p = 0; p < params.length; p++) { + for (Class type : TYPES) { + assertMatches(method().parameter(p, type), m, params[p] == type); + assertMatches(method().parameter(p, type.getTypeName()), m, params[p] == type); + } + } + assertFalse(method().parameter(params.length, Object.class).test(m)); + for (Class type : returns) { + assertMatches(method().returning(type), m, reflected.getReturnType() == type); + assertMatches(method().returning(type.getTypeName()), m, reflected.getReturnType() == type); + } + } + assertEquals(0, methods.size()); + } + + private static void addMethod( + ClassWriter writer, int id, Class returnType, Class... params) { + Type[] arguments = Arrays.stream(params).map(Type::getType).toArray(Type[]::new); + writer + .visitMethod( + ACC_PUBLIC | ACC_ABSTRACT, + "method" + id, + Type.getMethodDescriptor(Type.getType(returnType), arguments), + null, + null) + .visitEnd(); + } + + private static final class FixtureLoader extends ClassLoader { + Class define(byte[] bytes) { + return defineClass(null, bytes, 0, bytes.length); + } + } +} diff --git a/class-match/src/test/java/datadog/instrument/classmatch/InternalMatcherUnionTest.java b/class-match/src/test/java/datadog/instrument/classmatch/InternalMatcherUnionTest.java new file mode 100644 index 0000000..b3573f8 --- /dev/null +++ b/class-match/src/test/java/datadog/instrument/classmatch/InternalMatcherUnionTest.java @@ -0,0 +1,25 @@ +package datadog.instrument.classmatch; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +import org.junit.jupiter.api.Test; + +class InternalMatcherUnionTest { + @Test + void extraEntriesSurviveExpansionOfBothNestedOperands() { + Union left = new Union("a", "b"); + Union right = new Union("c", "d"); + Object[] entries = {left, right, "extra", "last"}; + Union combined = new Union(entries); + assertArrayEquals(new Object[] {"a", "b", "c", "d", "extra", "last"}, combined.matchers); + assertArrayEquals(new Object[] {left, right, "extra", "last"}, entries); + assertArrayEquals(new Object[] {"a", "b"}, left.matchers); + assertArrayEquals(new Object[] {"c", "d"}, right.matchers); + } + + private static final class Union extends InternalMatchers.MatcherUnion { + Union(Object... entries) { + super(entries); + } + } +} diff --git a/class-match/src/test/java/datadog/instrument/classmatch/MalformedClassFileTest.java b/class-match/src/test/java/datadog/instrument/classmatch/MalformedClassFileTest.java new file mode 100644 index 0000000..3327441 --- /dev/null +++ b/class-match/src/test/java/datadog/instrument/classmatch/MalformedClassFileTest.java @@ -0,0 +1,91 @@ +package datadog.instrument.classmatch; + +import static datadog.instrument.classmatch.BytecodeTestSupport.classWriter; +import static datadog.instrument.classmatch.MatcherTestSupport.methodOutline; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Arrays; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.objectweb.asm.Attribute; +import org.objectweb.asm.ByteVector; +import org.objectweb.asm.ClassWriter; + +class MalformedClassFileTest { + static Stream incompleteDescriptors() { + return Stream.of( + Arguments.of("(I", new int[0]), + Arguments.of("(Ljava/lang/String", new int[0]), + Arguments.of("([", new int[0]), + Arguments.of("(ILjava/lang/String", new int[] {2}), + Arguments.of("(II[", new int[] {2, 3})); + } + + @ParameterizedTest + @MethodSource("incompleteDescriptors") + void incompleteDescriptorsRetainOnlyPreviouslyParsedBoundaries( + String descriptor, int[] boundaries) { + MethodOutline method = methodOutline(descriptor); + for (int pass = 0; pass < 2; pass++) { + assertArrayEquals(boundaries, method.descriptorBoundaries()); + assertEquals(boundaries.length, method.parameterCount()); + } + } + + @ParameterizedTest + @ValueSource(ints = {0, 2, 13, 99}) + void invalidConstantPoolTagsAreRejected(int tag) { + byte[] bytes = classWriter("tests/BadConstantPool").toByteArray(); + bytes[10] = (byte) tag; // first constant pool entry follows magic/version/count + assertThrows(IllegalArgumentException.class, () -> ClassFile.header(bytes)); + assertThrows(IllegalArgumentException.class, () -> ClassFile.outline(bytes)); + } + + @Test + void truncatedClassFilesAreRejected() { + byte[] bytes = classWriter("tests/Truncated").toByteArray(); + for (int length = 0; length < bytes.length; length++) { + byte[] truncated = Arrays.copyOf(bytes, length); + assertThrows( + IndexOutOfBoundsException.class, () -> ClassFile.outline(truncated), "length " + length); + } + for (int length : new int[] {0, 8, 10, 11, bytes.length - 7}) { + byte[] truncated = Arrays.copyOf(bytes, length); + assertThrows( + IndexOutOfBoundsException.class, () -> ClassFile.header(truncated), "length " + length); + } + } + + @Test + void invalidAnnotationElementTagsAreRejected() { + ClassFile.annotationOfInterest("tests/BadAnnotation"); + ClassWriter writer = classWriter("tests/BadAnnotationValue"); + writer.visitAttribute(new InvalidAnnotation()); + byte[] bytes = writer.toByteArray(); + assertEquals("tests/BadAnnotationValue", ClassFile.header(bytes).className); + assertThrows(IllegalArgumentException.class, () -> ClassFile.outline(bytes)); + } + + private static final class InvalidAnnotation extends Attribute { + InvalidAnnotation() { + super("RuntimeVisibleAnnotations"); + } + + @Override + protected ByteVector write( + ClassWriter writer, byte[] code, int codeLength, int maxStack, int maxLocals) { + return new ByteVector() + .putShort(1) // one annotation + .putShort(writer.newUTF8("Ltests/BadAnnotation;")) + .putShort(1) // one element + .putShort(writer.newUTF8("value")) + .putByte('!'); // not a JVM annotation element tag + } + } +} diff --git a/class-match/src/test/java/datadog/instrument/classmatch/MatcherCompositionTest.java b/class-match/src/test/java/datadog/instrument/classmatch/MatcherCompositionTest.java new file mode 100644 index 0000000..2e1402f --- /dev/null +++ b/class-match/src/test/java/datadog/instrument/classmatch/MatcherCompositionTest.java @@ -0,0 +1,120 @@ +package datadog.instrument.classmatch; + +import static datadog.instrument.classmatch.MatcherTestSupport.assertMatches; +import static datadog.instrument.classmatch.MatcherTestSupport.emptyClass; +import static datadog.instrument.classmatch.MatcherTestSupport.fieldOutline; +import static datadog.instrument.classmatch.MatcherTestSupport.methodOutline; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.BinaryOperator; +import java.util.function.Function; +import java.util.function.IntFunction; +import java.util.function.Predicate; +import java.util.function.UnaryOperator; +import org.junit.jupiter.api.Test; + +/** The same composition contract applies to each predicate API. */ +class MatcherCompositionTest { + @Test + void classMatchers() { + checkComposition( + emptyClass(), + p -> (ClassMatcher) p::test, + ClassMatcher::and, + ClassMatcher::or, + StandardMatchers::not); + } + + @Test + void fieldMatchers() { + checkComposition( + fieldOutline("I"), + p -> (FieldMatcher) p::test, + FieldMatcher::and, + FieldMatcher::or, + StandardMatchers::not); + } + + @Test + void methodMatchers() { + checkComposition( + methodOutline("()V"), + p -> (MethodMatcher) p::test, + MethodMatcher::and, + MethodMatcher::or, + StandardMatchers::not); + } + + @Test + void typeMatchers() { + checkComposition( + (CharSequence) "sample/Type", + p -> (TypeMatcher) p::test, + TypeMatcher::and, + TypeMatcher::or, + StandardMatchers::not); + } + + private static > void checkComposition( + T candidate, + Function, M> factory, + BinaryOperator and, + BinaryOperator or, + UnaryOperator not) { + for (int bits = 0; bits < 16; bits++) { + boolean a = (bits & 1) != 0; + boolean b = (bits & 2) != 0; + boolean c = (bits & 4) != 0; + boolean d = (bits & 8) != 0; + M ma = factory.apply(ignored -> a), mb = factory.apply(ignored -> b); + M mc = factory.apply(ignored -> c), md = factory.apply(ignored -> d); + assertMatches(and.apply(ma, mb), candidate, a && b); + assertMatches(or.apply(ma, mb), candidate, a || b); + assertMatches(not.apply(ma), candidate, !a); + // Nest either operand or both, and mix AND/OR without changing grouping. + assertMatches(and.apply(and.apply(ma, mb), and.apply(mc, md)), candidate, a && b && c && d); + assertMatches(and.apply(and.apply(ma, mb), mc), candidate, a && b && c); + assertMatches(and.apply(ma, and.apply(mb, mc)), candidate, a && b && c); + assertMatches(or.apply(or.apply(ma, mb), or.apply(mc, md)), candidate, a || b || c || d); + assertMatches(or.apply(or.apply(ma, mb), mc), candidate, a || b || c); + assertMatches(or.apply(ma, or.apply(mb, mc)), candidate, a || b || c); + assertMatches(and.apply(or.apply(ma, mb), or.apply(mc, md)), candidate, (a || b) && (c || d)); + assertMatches( + or.apply(and.apply(ma, mb), and.apply(mc, md)), candidate, (a && b) || (c && d)); + } + + for (boolean conjunction : new boolean[] {true, false}) { + BinaryOperator combine = conjunction ? and : or; + for (int stop = 0; stop <= 4; stop++) { + int stopIndex = stop; + List calls = new ArrayList<>(); + IntFunction recording = + index -> + factory.apply( + actual -> { + assertSame(candidate, actual); + calls.add(index); + return index == stopIndex ? !conjunction : conjunction; + }); + M left = combine.apply(recording.apply(0), recording.apply(1)); + M right = combine.apply(recording.apply(2), recording.apply(3)); + assertEquals( + stop == 4 ? conjunction : !conjunction, combine.apply(left, right).test(candidate)); + assertEquals(Arrays.asList(0, 1, 2, 3).subList(0, Math.min(stop + 1, 4)), calls); + } + } + + M yes = factory.apply(ignored -> true), no = factory.apply(ignored -> false); + M all = and.apply(yes, yes), any = or.apply(no, no); + assertMatches(and.apply(all, no), candidate, false); + assertMatches(and.apply(no, all), candidate, false); + assertMatches(or.apply(any, yes), candidate, true); + assertMatches(or.apply(yes, any), candidate, true); + assertMatches(all, candidate, true); + assertMatches(any, candidate, false); + } +} diff --git a/class-match/src/test/java/datadog/instrument/classmatch/MatcherTestSupport.java b/class-match/src/test/java/datadog/instrument/classmatch/MatcherTestSupport.java new file mode 100644 index 0000000..04eeedb --- /dev/null +++ b/class-match/src/test/java/datadog/instrument/classmatch/MatcherTestSupport.java @@ -0,0 +1,76 @@ +package datadog.instrument.classmatch; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import datadog.instrument.utils.JVM; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.util.function.Predicate; +import java.util.stream.Stream; +import org.junit.jupiter.params.provider.Arguments; + +/** Small fixtures with explicit JVM descriptors so expectations don't use production conversion. */ +final class MatcherTestSupport { + private MatcherTestSupport() {} + + static Stream types() { + return Stream.of( + Arguments.of("boolean", boolean.class, "Z"), + Arguments.of("byte", byte.class, "B"), + Arguments.of("char", char.class, "C"), + Arguments.of("short", short.class, "S"), + Arguments.of("int", int.class, "I"), + Arguments.of("long", long.class, "J"), + Arguments.of("float", float.class, "F"), + Arguments.of("double", double.class, "D"), + Arguments.of("java.lang.String", String.class, "Ljava/lang/String;"), + Arguments.of("java/lang/String", String.class, "Ljava/lang/String;"), + Arguments.of("int[][]", int[][].class, "[[I"), + Arguments.of("java.lang.String[][]", String[][].class, "[[Ljava/lang/String;")); + } + + static FieldOutline fieldOutline(String descriptor) { + return fieldOutline(0, "value", descriptor); + } + + static FieldOutline fieldOutline(int access, String name, String descriptor) { + return new FieldOutline(access, name, descriptor); + } + + static MethodOutline methodOutline(String descriptor) { + return methodOutline(0, "execute", descriptor); + } + + static MethodOutline methodOutline(int access, String name, String descriptor) { + return new MethodOutline(access, name, descriptor, new String[0]); + } + + static ClassOutline classOutline( + FieldOutline[] fields, MethodOutline[] methods, String... annotations) { + return new ClassOutline( + 0, "sample/Example", "java/lang/Object", new String[0], fields, methods, annotations); + } + + static ClassOutline emptyClass() { + return classOutline(new FieldOutline[0], new MethodOutline[0]); + } + + static void assertMatches(Predicate matcher, T candidate, boolean expected) { + // Repeating on the same outline also exercises lazily cached descriptor information. + assertEquals(expected, matcher.test(candidate)); + assertEquals(expected, matcher.test(candidate)); + } + + static ClassOutline parse(Class fixture) { + String resource = "/" + fixture.getName().replace('.', '/') + ".class"; + try (InputStream in = fixture.getResourceAsStream(resource)) { + if (in == null) { + throw new AssertionError("Missing fixture " + resource); + } + return ClassFile.outline(JVM.readAllBytes(in)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/class-match/src/test/java/datadog/instrument/classmatch/MethodMatcherTest.java b/class-match/src/test/java/datadog/instrument/classmatch/MethodMatcherTest.java new file mode 100644 index 0000000..ad5835a --- /dev/null +++ b/class-match/src/test/java/datadog/instrument/classmatch/MethodMatcherTest.java @@ -0,0 +1,177 @@ +package datadog.instrument.classmatch; + +import static datadog.instrument.classmatch.AccessMatcher.INSTANCE; +import static datadog.instrument.classmatch.AccessMatcher.PUBLIC; +import static datadog.instrument.classmatch.AccessMatcher.STATIC; +import static datadog.instrument.classmatch.MatcherTestSupport.assertMatches; +import static datadog.instrument.classmatch.MatcherTestSupport.methodOutline; +import static datadog.instrument.classmatch.MethodMatcher.constructor; +import static datadog.instrument.classmatch.MethodMatcher.method; +import static datadog.instrument.classmatch.MethodMatcher.staticInitializer; +import static datadog.instrument.classmatch.StandardMatchers.nameStartsWith; +import static datadog.instrument.classmatch.StandardMatchers.namedOneOf; +import static datadog.instrument.classmatch.TypeMatcher.type; +import static datadog.instrument.classmatch.TypeMatcher.typeEndsWith; +import static datadog.instrument.classmatch.TypeMatcher.typeOneOf; +import static datadog.instrument.classmatch.TypeMatcher.typeStartsWith; + +import java.lang.reflect.Array; +import java.lang.reflect.Modifier; +import java.util.Collections; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +class MethodMatcherTest { + @ParameterizedTest + @MethodSource("datadog.instrument.classmatch.MatcherTestSupport#types") + void exactSignatureOverloads(String name, Class type, String descriptor) { + MethodOutline candidate = methodOutline("(" + descriptor + descriptor + ")" + descriptor); + assertMatches(method("execute").parameters(name, name).returning(name), candidate, true); + assertMatches(method("execute").parameters(type, type).returning(type), candidate, true); + assertMatches(method().parameter(0, name).parameter(1, type), candidate, true); + assertMatches(method().parameter(0, type).parameter(1, name), candidate, true); + assertMatches(method().parameters(2), candidate, true); + assertMatches(method().parameters(1), candidate, false); + assertMatches(method().parameters(type), candidate, false); + assertMatches(method().parameter(2, type), candidate, false); + assertMatches(method().parameter(3, name), candidate, false); + assertMatches(method().parameter(0, Object.class), candidate, false); + assertMatches(method().parameter(1, "java.lang.Object"), candidate, false); + assertMatches(method().returning(Object.class), candidate, false); + assertMatches(method().noParameters(), candidate, false); + } + + @ParameterizedTest + @MethodSource("datadog.instrument.classmatch.MatcherTestSupport#types") + void exactReturnTypesDistinguishArraysFromTheirComponents( + String name, Class type, String descriptor) { + MethodOutline scalar = methodOutline("()" + descriptor); + MethodOutline array = methodOutline("()[" + descriptor); + Class arrayType = Array.newInstance(type, 0).getClass(); + assertMatches(method().returning(name), array, false); + assertMatches(method().returning(type), array, false); + assertMatches(method().returning(name + "[]"), scalar, false); + assertMatches(method().returning(arrayType), scalar, false); + assertMatches(method().returning(name + "[]"), array, true); + assertMatches(method().returning(arrayType), array, true); + } + + @Test + void exactReturnTypesDoNotMatchSuffixesOfOtherClassNames() { + MethodOutline candidate = methodOutline("()Lsample/LA;"); + assertMatches(method().returning("A"), candidate, false); + assertMatches(method().returning("sample.LA"), candidate, true); + } + + @Test + void emptySignaturesAndVoidReturn() { + MethodOutline candidate = methodOutline("()V"); + assertMatches(method().noParameters().returning(void.class), candidate, true); + assertMatches(method().parameters(0).returning("void"), candidate, true); + assertMatches(method().parameters(new String[0]), candidate, true); + assertMatches(method().parameters(new Class[0]), candidate, true); + assertMatches(method().parameter(0, int.class), candidate, false); + assertMatches(method().parameter(0, "java.lang.String"), candidate, false); + assertMatches(method().parameters(1), candidate, false); + assertMatches(method().parameters(-1), candidate, false); + } + + @Test + void mixedParametersCountTypesRatherThanSlotsOrArrayDimensions() { + MethodOutline candidate = methodOutline("(JD[[I[Ljava/lang/String;Ljava/lang/Object;Z)V"); + assertMatches(method().parameters(6), candidate, true); + assertMatches( + method() + .parameters( + long.class, + double.class, + int[][].class, + String[].class, + Object.class, + boolean.class), + candidate, + true); + assertMatches(method().parameter(4, Object.class).parameter(5, "boolean"), candidate, true); + assertMatches(method().parameter(2, int[].class), candidate, false); + assertMatches(method().parameter(6, void.class), candidate, false); + } + + @Test + void objectParameterPredicatesAndReturnPredicates() { + // Final object-parameter boundary regressions belong to PR #144's MethodOutlineTest. + MethodOutline candidate = + methodOutline("(Ljava/lang/String;ILjava/lang/Object;Z)Ljava/lang/String;"); + assertMatches(method().parameter(0, type("java.lang.String")), candidate, true); + assertMatches( + method().parameter(2, typeOneOf("java.lang.String", "java.lang.Object")), candidate, true); + assertMatches( + method().parameter(0, typeStartsWith("java.").and(typeEndsWith("String"))), + candidate, + true); + assertMatches(method().parameter(0, type("java.lang.Object")), candidate, false); + assertMatches(method().returning(type("java.lang.String")), candidate, true); + assertMatches( + method().returning(typeOneOf("java.lang.Object", "java.lang.String")), candidate, true); + assertMatches( + method().returning(typeStartsWith("java.lang.").and(typeEndsWith("String"))), + candidate, + true); + assertMatches(method().returning(type("java.lang.Object")), candidate, false); + } + + @ParameterizedTest + @ValueSource( + strings = {"()V", "()I", "()[I", "()[[Ljava/lang/String;", "(I)V", "([Ljava/lang/String;)V"}) + void unsupportedOrAbsentTypesDoNotInvokeTypePredicates(String descriptor) { + TypeMatcher unexpected = + t -> { + throw new AssertionError("not an object type"); + }; + MethodOutline candidate = methodOutline(descriptor); + assertMatches(method().parameter(0, unexpected), candidate, false); + assertMatches(method().parameter(10, unexpected), candidate, false); + assertMatches(method().returning(unexpected), candidate, false); + } + + @Test + void namesSpecialMethodsAndAccess() { + MethodOutline ordinary = methodOutline(Modifier.PUBLIC | Modifier.STATIC, "execute", "()V"); + assertMatches( + method(namedOneOf("other", "execute")).access(PUBLIC.and(STATIC)), ordinary, true); + assertMatches(method(nameStartsWith("exec")), ordinary, true); + assertMatches(method("Execute"), ordinary, false); + assertMatches(method().access(INSTANCE), ordinary, false); + assertMatches(constructor(), ordinary, false); + assertMatches(staticInitializer(), ordinary, false); + MethodOutline init = methodOutline(0, "", "()V"); + MethodOutline clinit = methodOutline(Modifier.STATIC, "", "()V"); + assertMatches(constructor().noParameters(), init, true); + assertMatches(staticInitializer(), clinit, true); + assertMatches(constructor(), clinit, false); + assertMatches(staticInitializer(), init, false); + assertMatches(method(), init, true); + assertMatches(method(), clinit, true); + MethodMatcher unexpected = + m -> { + throw new AssertionError("all methods should short circuit"); + }; + assertMatches(method().or(unexpected), ordinary, true); + } + + @Test + void annotationOverloadsNormalizeAndHandleEmptyAnnotations() { + MethodOutline candidate = + new MethodOutline(0, "execute", "()V", new String[] {"sample/Other", "sample/Marker"}); + assertMatches(method().annotatedWith("sample.Marker"), candidate, true); + assertMatches(method().annotatedWith("sample/Marker"), candidate, true); + assertMatches(method().annotatedWith("sample.Missing", "sample.Marker"), candidate, true); + assertMatches( + method().annotatedWith(Collections.singletonList("sample.Marker")), candidate, true); + assertMatches(method().annotatedWith("sample.Missing"), candidate, false); + assertMatches(method().annotatedWith(new String[0]), candidate, false); + assertMatches(method().annotatedWith(Collections.emptyList()), candidate, false); + assertMatches(method().annotatedWith("sample.Marker"), methodOutline("()V"), false); + } +} diff --git a/class-match/src/test/java/datadog/instrument/classmatch/OutlineTypeViewsTest.java b/class-match/src/test/java/datadog/instrument/classmatch/OutlineTypeViewsTest.java new file mode 100644 index 0000000..5a729c1 --- /dev/null +++ b/class-match/src/test/java/datadog/instrument/classmatch/OutlineTypeViewsTest.java @@ -0,0 +1,78 @@ +package datadog.instrument.classmatch; + +import static datadog.instrument.classmatch.MatcherTestSupport.fieldOutline; +import static datadog.instrument.classmatch.MatcherTestSupport.methodOutline; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** Descriptor views beyond the boundary regressions owned by PR #144. */ +class OutlineTypeViewsTest { + @ParameterizedTest + @ValueSource(strings = {"A", "java/lang/String", "sample/Outer$Inner", "例/型"}) + void fieldParameterAndReturnViewsHaveStringContentAndHashes(String name) { + FieldOutline field = fieldOutline("L" + name + ";"); + // Keep an additional primitive parameter: final object parameter coverage is in PR #144. + MethodOutline method = methodOutline("(L" + name + ";I)L" + name + ";"); + MethodOutline noParameters = methodOutline("()L" + name + ";"); + for (int pass = 0; pass < 2; pass++) { + assertView(name, field.typeString()); + assertView(name, method.parameterTypeString(0)); + assertView(name, method.returnTypeString()); + assertView(name, noParameters.returnTypeString()); + assertEquals(2, method.parameterCount()); + assertEquals(0, noParameters.parameterCount()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"Z", "B", "C", "D", "F", "I", "J", "S", "[I", "[[Ljava/lang/String;"}) + void primitiveAndArrayDescriptorsHaveNoObjectViews(String descriptor) { + FieldOutline field = fieldOutline(descriptor); + MethodOutline method = methodOutline("(" + descriptor + ")" + descriptor); + for (int pass = 0; pass < 2; pass++) { + assertNull(field.typeString()); + assertNull(method.parameterTypeString(0)); + assertNull(method.parameterTypeString(1)); + assertNull(method.returnTypeString()); + assertEquals(1, method.parameterCount()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"()V", "(I)V", "(Ljava/lang/String;I)V"}) + void voidReturnsAndMissingParametersHaveNoViews(String descriptor) { + MethodOutline method = methodOutline(descriptor); + assertNull(method.returnTypeString()); + assertNull(method.parameterTypeString(method.parameterCount())); + assertNull(method.parameterTypeString(method.parameterCount() + 1)); + } + + @SuppressFBWarnings( + value = "EC_UNRELATED_TYPES", + justification = "TypeString intentionally supports content equality with any CharSequence") + private static void assertView(String expected, TypeString actual) { + assertNotNull(actual); + assertEquals(expected.length(), actual.length()); + assertEquals(expected, actual.toString()); + assertEquals(expected.hashCode(), actual.hashCode()); + assertTrue(actual.equals(expected)); + assertTrue(actual.equals(new StringBuilder(expected))); + assertTrue(actual.equals(actual)); + assertFalse(actual.equals(expected + "Extra")); + assertFalse(actual.equals("!" + expected.substring(1))); + assertFalse(actual.equals(null)); + assertFalse(actual.equals(123)); + for (int i = 0; i < expected.length(); i++) { + assertEquals(expected.charAt(i), actual.charAt(i)); + } + assertThrows(UnsupportedOperationException.class, () -> actual.subSequence(0, 1)); + } +} diff --git a/class-match/src/test/java/datadog/instrument/classmatch/StandardMatchersTest.java b/class-match/src/test/java/datadog/instrument/classmatch/StandardMatchersTest.java new file mode 100644 index 0000000..aeb0757 --- /dev/null +++ b/class-match/src/test/java/datadog/instrument/classmatch/StandardMatchersTest.java @@ -0,0 +1,53 @@ +package datadog.instrument.classmatch; + +import static datadog.instrument.classmatch.StandardMatchers.nameEndsWith; +import static datadog.instrument.classmatch.StandardMatchers.nameStartsWith; +import static datadog.instrument.classmatch.StandardMatchers.named; +import static datadog.instrument.classmatch.StandardMatchers.namedOneOf; +import static datadog.instrument.classmatch.StandardMatchers.not; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.Predicate; +import org.junit.jupiter.api.Test; + +class StandardMatchersTest { + @Test + void exactNamesAreCaseSensitiveAndDoNotNormalize() { + assertTrue(named("sample.Type").test("sample.Type")); + assertFalse(named("sample.Type").test("sample/Type")); + assertFalse(named("sample.Type").test("Sample.Type")); + assertFalse(named("sample.Type").test(null)); + assertTrue(named("").test("")); + } + + @Test + void membershipOverloadsAndSnapshot() { + List names = new ArrayList<>(Arrays.asList("one", "two", "one")); + Predicate matcher = namedOneOf(names); + names.clear(); + assertTrue(matcher.test("one")); + assertTrue(matcher.test("two")); + assertFalse(matcher.test("three")); + assertTrue(namedOneOf("one", "two").test("two")); + assertFalse(namedOneOf().test("")); + assertFalse(namedOneOf(names).test("one")); + } + + @Test + void prefixSuffixAndGenericNegation() { + Predicate matcher = nameStartsWith("get").and(nameEndsWith("Value")); + assertTrue(matcher.test("getValue")); + assertFalse(matcher.test("setValue")); + assertFalse(matcher.test("getValues")); + assertTrue(nameStartsWith("").test("")); + assertTrue(nameEndsWith("").test("")); + assertFalse(nameStartsWith("longer").test("long")); + assertFalse(nameEndsWith("longer").test("long")); + assertTrue(not(matcher).test("setValue")); + assertFalse(not(matcher).test("getValue")); + } +} diff --git a/class-match/src/test/java/datadog/instrument/classmatch/TypeMatcherTest.java b/class-match/src/test/java/datadog/instrument/classmatch/TypeMatcherTest.java new file mode 100644 index 0000000..f71e941 --- /dev/null +++ b/class-match/src/test/java/datadog/instrument/classmatch/TypeMatcherTest.java @@ -0,0 +1,75 @@ +package datadog.instrument.classmatch; + +import static datadog.instrument.classmatch.MatcherTestSupport.assertMatches; +import static datadog.instrument.classmatch.TypeMatcher.type; +import static datadog.instrument.classmatch.TypeMatcher.typeEndsWith; +import static datadog.instrument.classmatch.TypeMatcher.typeOneOf; +import static datadog.instrument.classmatch.TypeMatcher.typeStartsWith; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +class TypeMatcherTest { + static Stream names() { + return Stream.of("java/lang/String", "sample/Outer$Inner", "例/型", "A", "") + .flatMap(name -> Stream.of(Arguments.of(name, name), Arguments.of(name, typeString(name)))); + } + + private static TypeString typeString(String name) { + return new TypeString("L" + name + ";", 1, name.length() + 1, name.hashCode()); + } + + @ParameterizedTest + @MethodSource("names") + void exactNamesNormalizeFactoriesAndAcceptCharSequences(String name, CharSequence candidate) { + assertMatches(type(name), candidate, true); + assertMatches(type(name.replace('/', '.')), candidate, true); + assertMatches(type("missing/Type"), candidate, false); + assertMatches(typeOneOf("missing.Type", name.replace('/', '.')), candidate, true); + assertMatches(typeOneOf(Arrays.asList("missing.Type", name)), candidate, true); + assertMatches(typeOneOf(), candidate, false); + assertMatches(typeOneOf(Collections.emptyList()), candidate, false); + } + + @Test + void exactMatchingAcceptsCharSequencesWithoutStringHashes() { + assertMatches(type("java.lang.String"), new StringBuilder("java/lang/String"), true); + assertMatches(type("java.lang.Object"), new StringBuilder("java/lang/String"), false); + } + + @Test + void prefixesAndSuffixesNormalizeFactoryArguments() { + for (CharSequence candidate : + new CharSequence[] { + "java/lang/String", new StringBuilder("java/lang/String"), typeString("java/lang/String") + }) { + assertMatches(typeStartsWith("java.lang."), candidate, true); + assertMatches(typeEndsWith("lang.String"), candidate, true); + assertMatches(typeStartsWith(""), candidate, true); + assertMatches(typeEndsWith(""), candidate, true); + assertMatches(typeStartsWith("javax."), candidate, false); + assertMatches(typeEndsWith("Object"), candidate, false); + assertMatches(typeStartsWith("java/lang/StringExtra"), candidate, false); + assertMatches(typeEndsWith("Extra/java/lang/String"), candidate, false); + } + } + + @Test + void membershipTakesASnapshotAndNormalizesDuplicates() { + List names = + new ArrayList<>(Arrays.asList("sample.Type", "sample/Type", "sample.Other")); + TypeMatcher matcher = typeOneOf(names); + names.clear(); + assertMatches(matcher, "sample/Type", true); + assertMatches(matcher, "sample/Other", true); + assertMatches(matcher, "sample/Missing", false); + assertMatches(type("sample.Type"), "sample.Type", false); + } +}