From 66417c27463f80cf2369c8990a7d2118575f30ed Mon Sep 17 00:00:00 2001 From: Henry Coles Date: Tue, 28 Jul 2026 18:05:50 +0100 Subject: [PATCH] normalise record attribute order on first load --- .../environment/NormaliseRecordsPlugin.java | 108 ++++++++++++++++++ ...ationtest.environment.TransformationPlugin | 3 +- .../NormaliseRecordsTransformerTest.java | 27 +++++ .../environment/NormaliseVisitorTest.java | 44 +++++++ .../sampleClasses/SafeRange.class.bin | Bin 0 -> 2021 bytes 5 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 pitest/src/main/java/org/pitest/mutationtest/environment/NormaliseRecordsPlugin.java create mode 100644 pitest/src/test/java/org/pitest/mutationtest/environment/NormaliseRecordsTransformerTest.java create mode 100644 pitest/src/test/java/org/pitest/mutationtest/environment/NormaliseVisitorTest.java create mode 100644 pitest/src/test/resources/sampleClasses/SafeRange.class.bin diff --git a/pitest/src/main/java/org/pitest/mutationtest/environment/NormaliseRecordsPlugin.java b/pitest/src/main/java/org/pitest/mutationtest/environment/NormaliseRecordsPlugin.java new file mode 100644 index 000000000..172c8d815 --- /dev/null +++ b/pitest/src/main/java/org/pitest/mutationtest/environment/NormaliseRecordsPlugin.java @@ -0,0 +1,108 @@ +package org.pitest.mutationtest.environment; + +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.MethodVisitor; +import org.pitest.bytecode.ASMVersion; +import org.pitest.bytecode.FrameOptions; +import org.pitest.classinfo.ComputeClassWriter; +import org.pitest.classpath.ClassloaderByteArraySource; + +import java.lang.instrument.ClassFileTransformer; +import java.security.ProtectionDomain; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + + +/** + * Workaround for an apparent ASM bug - attributes do not come out in + * the same order when records are transformed, resulting in a changed + * signature. We therefore transform records on first load to + * ensure the order remains the same. + */ +public class NormaliseRecordsPlugin implements TransformationPlugin { + @Override + public String description() { + return "Normalise record attributes"; + } + + @Override + public ClassFileTransformer makeMutationTransformer() { + return new NormaliseRecordsTransformer(); + } +} + +class NormaliseRecordsTransformer implements ClassFileTransformer { + + private final Map computeCache = new ConcurrentHashMap<>(); + + @Override + public byte[] transform(final ClassLoader loader, final String className, + final Class classBeingRedefined, + final ProtectionDomain protectionDomain, final byte[] classfileBuffer) { + + if (shouldInclude(className)) { + final ClassReader reader = new ClassReader(classfileBuffer); + final ClassWriter writer = new ComputeClassWriter( + new ClassloaderByteArraySource(loader), this.computeCache, + FrameOptions.pickFlags(classfileBuffer)); + + var normaliser = new NormaliseVisitor(writer); + reader.accept(normaliser, ClassReader.EXPAND_FRAMES); + + if (normaliser.isRecord) { + return writer.toByteArray(); + } + return null; + + } else { + return null; + } + } + + private boolean shouldInclude(final String className) { + // cheaply exclude classes that we won't be mutating + return !className.startsWith("java/") + && !className.startsWith("javax/") + && !className.startsWith("org/junit"); + } +} + + +/** + * Although this appears to be a no-op, in fact it ensures that + * attributes are ordered in the same way as they would be after a transformation + * by a mutation operator + */ +class NormaliseVisitor extends ClassVisitor { + + boolean isRecord = false; + + NormaliseVisitor(final ClassVisitor arg0) { + super(ASMVersion.ASM_VERSION, arg0); + } + + @Override + public void visit(final int version, final int access, final String name, + final String signature, final String superName, final String[] interfaces) { + isRecord = "java/lang/Record".equals(superName); + super.visit(version, access, name, signature, superName, interfaces); + } + + + @Override + public MethodVisitor visitMethod( + final int access, + final String name, + final String descriptor, + final String signature, + final String[] exceptions) { + // skip processing if we're not going to use the output + if (isRecord) { + return super.visitMethod(access, name, descriptor, signature, exceptions); + } + return null; + } + +} diff --git a/pitest/src/main/resources/META-INF/services/org.pitest.mutationtest.environment.TransformationPlugin b/pitest/src/main/resources/META-INF/services/org.pitest.mutationtest.environment.TransformationPlugin index b7cd18fbb..f7e453ef9 100644 --- a/pitest/src/main/resources/META-INF/services/org.pitest.mutationtest.environment.TransformationPlugin +++ b/pitest/src/main/resources/META-INF/services/org.pitest.mutationtest.environment.TransformationPlugin @@ -1,2 +1,3 @@ org.pitest.mutationtest.mocksupport.JavassistTransformation -org.pitest.mutationtest.jacoco.DisableJacocoTransformation \ No newline at end of file +org.pitest.mutationtest.jacoco.DisableJacocoTransformation +org.pitest.mutationtest.environment.NormaliseRecordsPlugin \ No newline at end of file diff --git a/pitest/src/test/java/org/pitest/mutationtest/environment/NormaliseRecordsTransformerTest.java b/pitest/src/test/java/org/pitest/mutationtest/environment/NormaliseRecordsTransformerTest.java new file mode 100644 index 000000000..b5444c525 --- /dev/null +++ b/pitest/src/test/java/org/pitest/mutationtest/environment/NormaliseRecordsTransformerTest.java @@ -0,0 +1,27 @@ +package org.pitest.mutationtest.environment; + +import org.junit.Test; +import org.pitest.classinfo.ClassByteArraySource; +import org.pitest.classpath.ClassloaderByteArraySource; +import org.pitest.util.IsolationUtils; + +import static org.assertj.core.api.Assertions.assertThat; + + +public class NormaliseRecordsTransformerTest { + + ClassLoader loader = IsolationUtils.getContextClassLoader(); + ClassByteArraySource bytes = new ClassloaderByteArraySource(this.loader); + + @Test + public void doesNotTransformJDKClasses() { + byte[] actual = transform(String.class); + assertThat(actual).isNull(); + } + + private byte[] transform(final Class clazz) { + NormaliseRecordsTransformer testee = new NormaliseRecordsTransformer(); + return testee.transform(loader, clazz.getName(), null, + null, this.bytes.getBytes(clazz.getName()).get()); + } +} \ No newline at end of file diff --git a/pitest/src/test/java/org/pitest/mutationtest/environment/NormaliseVisitorTest.java b/pitest/src/test/java/org/pitest/mutationtest/environment/NormaliseVisitorTest.java new file mode 100644 index 000000000..ec3efc97d --- /dev/null +++ b/pitest/src/test/java/org/pitest/mutationtest/environment/NormaliseVisitorTest.java @@ -0,0 +1,44 @@ +package org.pitest.mutationtest.environment; + +import org.junit.Test; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.pitest.bytecode.FrameOptions; +import org.pitest.classinfo.ComputeClassWriter; +import org.pitest.classpath.ClassloaderByteArraySource; +import org.pitest.util.IsolationUtils; +import org.pitest.util.ResourceFolderByteArraySource; + +import java.util.HashMap; + +import static org.assertj.core.api.Assertions.assertThat; + +public class NormaliseVisitorTest { + + @Test + public void recognisesRecords() { + byte[] aRecord = new ResourceFolderByteArraySource().getBytes("SafeRange").get(); + var actual = transform(aRecord); + assertThat(actual.isRecord).isTrue(); + } + + @Test + public void recognisesNonRecord() { + byte[] bytes = ClassloaderByteArraySource.fromContext() + .getBytes("com/example/JUnitThreeTest").get(); + var actual = transform(bytes); + assertThat(actual.isRecord).isFalse(); + } + + private NormaliseVisitor transform(byte[] input) { + final ClassReader reader = new ClassReader(input); + final ClassWriter writer = new ComputeClassWriter( + new ClassloaderByteArraySource(IsolationUtils.getContextClassLoader()), new HashMap<>(), + FrameOptions.pickFlags(input)); + + var normaliser = new NormaliseVisitor(writer); + reader.accept(normaliser, ClassReader.EXPAND_FRAMES); + return normaliser; + } + +} \ No newline at end of file diff --git a/pitest/src/test/resources/sampleClasses/SafeRange.class.bin b/pitest/src/test/resources/sampleClasses/SafeRange.class.bin new file mode 100644 index 0000000000000000000000000000000000000000..288226367695d40fd1c7aca0f7ef70e9a709749c GIT binary patch literal 2021 zcmb7EU2hvj6g^`*S;z5LoYbW)O`#uPJ86~z<;$VaCJl9Q3QDR-g{R4S8gIScHM{Fl z{u2*WqKcGC@QB2ZLY&!MCvM_I@Zj;x-Fwfu_uM=4_df@J0QeH^98yRd$mB7FX@T-H z`COWgbob0HWqE!_V0zVd?eLC3x>nzrK^8Lxa(T=mFL2KC`lfmz`vXUr+w!T}Vx$t7 z3PS0J{N9f6Lc6cbwr5FaU52WuL5g{lG(uXr9RqU$SB{ulj-&RZvmMg;{tHVDLfdn5 zDCMfK}(#p;qund)!OgafxYXf$G;5JUDx%fD+MLf?Y8IdnO0X>J>@_3Wnca5 z`Mr_Bbcc@Psvt0RV&)^BBvG0InYgf0+jiBXVSiWokM$RUO4Q^X>D&687&BqlCfWH@ zgyhg|s<7*IzLz?KQ2BIb2jZ@BFlzM^kz*ZaIt5OO)!USVgm8foN7IxvCz{_DwMwQU zYTTiW0>abjAB82KnD+?n*c1!ow!NWmsSR6~UOXKC6%E3EjxJF7p67)@=*vM|XHc5P zYL1uhj)AZ8_!@WLn0`O*nHeXLgb2*+j!*<%j@Mwj&%K^9W5);5?Kmp9-uArSaL^p9 zCy`;yk_76hSXY7N+k?>a$3xZ}83f~o1mnk(Ami+BT#ZD=EY?^;^eQxY<4Xbte+7Yj z%XO6>F{!}71A!YyJvgH@0}llj&vs=GVjXacFHQ;q*SS{o)5ZOJyn9)QdEkBSD*8D( zVKr#sTY@xd^x4#JCI3r}eTc@=D;SOCS17!sDTO-MX}SXj8eHFrU9p5^o*0zS>Yb)a z;U=v!DVntjQ9fm)s@yW6Dv@+v0xK}NYJMM?h+w7hJB+_D`x@mZ`>6cMV`1$08uL%I zhc@ry+;5CbN9k2r3rKCCK*l1fTo?JQ>U4GOAXl932i#|nDitxu7x7lYum7V08wYp` z(7-3_j)9B>XpRIQP6&S+`1L=9U&TjB_$tX#jL9~Z4yf)a@zvPpQ1&c7#_cHYS$x88 zT1Ty?u4788bXFBp2~0973!|2lCPEedKU4;vGLIBK!{_{Oa5rf-Cr#!T^e?iCCi|4d TJ*?wCHfYPy_6=J278m{ndQ`87 literal 0 HcmV?d00001