Repository: Col-E/Recaf Branch: master Commit: a9d4b982d69d Files: 1323 Total size: 7.0 MB Directory structure: gitextract_kfvnwn9t/ ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── feature_request.md │ │ └── other.md │ └── workflows/ │ └── build.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── PRIMER.md ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── build.gradle ├── codecov.yml ├── docs/ │ ├── README.md │ └── index.html ├── gradle/ │ ├── libs.versions.toml │ ├── tasks.gradle │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── jitpack.yml ├── libs/ │ ├── README.md │ └── kotlin-metadata.jar ├── recaf-core/ │ ├── build.gradle │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── software/ │ │ │ └── coley/ │ │ │ └── recaf/ │ │ │ ├── Bootstrap.java │ │ │ ├── ExitCodes.java │ │ │ ├── ExitDebugLoggingHook.java │ │ │ ├── Recaf.java │ │ │ ├── RecafConstants.java │ │ │ ├── analytics/ │ │ │ │ ├── SystemInformation.java │ │ │ │ └── logging/ │ │ │ │ ├── DebuggingLogger.java │ │ │ │ ├── InterceptingLogger.java │ │ │ │ ├── LogConsumer.java │ │ │ │ ├── Logging.java │ │ │ │ └── RecafLoggingFilter.java │ │ │ ├── behavior/ │ │ │ │ ├── Closing.java │ │ │ │ ├── PriorityKeys.java │ │ │ │ └── PrioritySortable.java │ │ │ ├── cdi/ │ │ │ │ ├── EagerInitialization.java │ │ │ │ ├── EagerInitializationExtension.java │ │ │ │ ├── InitializationEvent.java │ │ │ │ ├── InitializationStage.java │ │ │ │ └── UiInitializationEvent.java │ │ │ ├── config/ │ │ │ │ ├── BasicCollectionConfigValue.java │ │ │ │ ├── BasicConfigContainer.java │ │ │ │ ├── BasicConfigValue.java │ │ │ │ ├── BasicMapConfigValue.java │ │ │ │ ├── ConfigCollectionValue.java │ │ │ │ ├── ConfigContainer.java │ │ │ │ ├── ConfigGroups.java │ │ │ │ ├── ConfigMapValue.java │ │ │ │ ├── ConfigPersistence.java │ │ │ │ ├── ConfigValue.java │ │ │ │ └── RestoreAwareConfigContainer.java │ │ │ ├── info/ │ │ │ │ ├── Accessed.java │ │ │ │ ├── AndroidChunkFileInfo.java │ │ │ │ ├── AndroidClassInfo.java │ │ │ │ ├── ApkFileInfo.java │ │ │ │ ├── ArscFileInfo.java │ │ │ │ ├── AudioFileInfo.java │ │ │ │ ├── BasicAndroidChunkFileInfo.java │ │ │ │ ├── BasicAndroidClassInfo.java │ │ │ │ ├── BasicApkFileInfo.java │ │ │ │ ├── BasicArscFileInfo.java │ │ │ │ ├── BasicAudioFileInfo.java │ │ │ │ ├── BasicBinaryXmlFileInfo.java │ │ │ │ ├── BasicClassInfo.java │ │ │ │ ├── BasicDexFileInfo.java │ │ │ │ ├── BasicFileInfo.java │ │ │ │ ├── BasicImageFileInfo.java │ │ │ │ ├── BasicInnerClassInfo.java │ │ │ │ ├── BasicJModFileInfo.java │ │ │ │ ├── BasicJarFileInfo.java │ │ │ │ ├── BasicJvmClassInfo.java │ │ │ │ ├── BasicModulesFileInfo.java │ │ │ │ ├── BasicNativeLibraryFileInfo.java │ │ │ │ ├── BasicTextFileInfo.java │ │ │ │ ├── BasicVideoFileInfo.java │ │ │ │ ├── BasicWarFileInfo.java │ │ │ │ ├── BasicZipFileInfo.java │ │ │ │ ├── BinaryXmlFileInfo.java │ │ │ │ ├── ClassInfo.java │ │ │ │ ├── DexFileInfo.java │ │ │ │ ├── FileInfo.java │ │ │ │ ├── ImageFileInfo.java │ │ │ │ ├── Info.java │ │ │ │ ├── InnerClassInfo.java │ │ │ │ ├── JModFileInfo.java │ │ │ │ ├── JarFileInfo.java │ │ │ │ ├── JvmClassInfo.java │ │ │ │ ├── ModulesFileInfo.java │ │ │ │ ├── Named.java │ │ │ │ ├── NativeLibraryFileInfo.java │ │ │ │ ├── StubClassInfo.java │ │ │ │ ├── StubFieldMember.java │ │ │ │ ├── StubFileInfo.java │ │ │ │ ├── StubMember.java │ │ │ │ ├── StubMethodMember.java │ │ │ │ ├── TextFileInfo.java │ │ │ │ ├── VideoFileInfo.java │ │ │ │ ├── WarFileInfo.java │ │ │ │ ├── ZipFileInfo.java │ │ │ │ ├── annotation/ │ │ │ │ │ ├── Annotated.java │ │ │ │ │ ├── AnnotationArrayReference.java │ │ │ │ │ ├── AnnotationElement.java │ │ │ │ │ ├── AnnotationEnumReference.java │ │ │ │ │ ├── AnnotationInfo.java │ │ │ │ │ ├── BasicAnnotationArrayReference.java │ │ │ │ │ ├── BasicAnnotationElement.java │ │ │ │ │ ├── BasicAnnotationEnumReference.java │ │ │ │ │ ├── BasicAnnotationInfo.java │ │ │ │ │ ├── BasicTypeAnnotationInfo.java │ │ │ │ │ └── TypeAnnotationInfo.java │ │ │ │ ├── builder/ │ │ │ │ │ ├── AbstractClassInfoBuilder.java │ │ │ │ │ ├── AndroidClassInfoBuilder.java │ │ │ │ │ ├── ApkFileInfoBuilder.java │ │ │ │ │ ├── ArscFileInfoBuilder.java │ │ │ │ │ ├── AudioFileInfoBuilder.java │ │ │ │ │ ├── BinaryXmlFileInfoBuilder.java │ │ │ │ │ ├── ChunkFileInfoBuilder.java │ │ │ │ │ ├── DexFileInfoBuilder.java │ │ │ │ │ ├── FileInfoBuilder.java │ │ │ │ │ ├── ImageFileInfoBuilder.java │ │ │ │ │ ├── JModFileInfoBuilder.java │ │ │ │ │ ├── JarFileInfoBuilder.java │ │ │ │ │ ├── JvmClassInfoBuilder.java │ │ │ │ │ ├── ModulesFileInfoBuilder.java │ │ │ │ │ ├── NativeLibraryFileInfoBuilder.java │ │ │ │ │ ├── TextFileInfoBuilder.java │ │ │ │ │ ├── VideoFileInfoBuilder.java │ │ │ │ │ ├── WarFileInfoBuilder.java │ │ │ │ │ └── ZipFileInfoBuilder.java │ │ │ │ ├── member/ │ │ │ │ │ ├── BasicFieldMember.java │ │ │ │ │ ├── BasicLocalVariable.java │ │ │ │ │ ├── BasicMember.java │ │ │ │ │ ├── BasicMethodMember.java │ │ │ │ │ ├── ClassMember.java │ │ │ │ │ ├── FieldMember.java │ │ │ │ │ ├── LocalVariable.java │ │ │ │ │ └── MethodMember.java │ │ │ │ └── properties/ │ │ │ │ ├── BasicProperty.java │ │ │ │ ├── BasicPropertyContainer.java │ │ │ │ ├── Property.java │ │ │ │ ├── PropertyContainer.java │ │ │ │ └── builtin/ │ │ │ │ ├── BinaryXmlDecodedProperty.java │ │ │ │ ├── CachedDecompileProperty.java │ │ │ │ ├── HasMappedReferenceProperty.java │ │ │ │ ├── IllegalClassSuspectProperty.java │ │ │ │ ├── InputFilePathProperty.java │ │ │ │ ├── MemberIndexAcceleratorProperty.java │ │ │ │ ├── OriginalClassNameProperty.java │ │ │ │ ├── PathOriginalNameProperty.java │ │ │ │ ├── PathPrefixProperty.java │ │ │ │ ├── PathSuffixProperty.java │ │ │ │ ├── ReferencedClassesProperty.java │ │ │ │ ├── RemapOriginTaskProperty.java │ │ │ │ ├── RemoteClassloaderProperty.java │ │ │ │ ├── StringDefinitionsProperty.java │ │ │ │ ├── ThrowableProperty.java │ │ │ │ ├── UnknownAttributesProperty.java │ │ │ │ ├── VersionedClassProperty.java │ │ │ │ ├── ZipAccessTimeProperty.java │ │ │ │ ├── ZipCommentProperty.java │ │ │ │ ├── ZipCompressionProperty.java │ │ │ │ ├── ZipCreationTimeProperty.java │ │ │ │ ├── ZipEntryIndexProperty.java │ │ │ │ ├── ZipMarkerProperty.java │ │ │ │ ├── ZipModificationTimeProperty.java │ │ │ │ └── ZipPrefixDataProperty.java │ │ │ ├── launch/ │ │ │ │ ├── LaunchArguments.java │ │ │ │ ├── LaunchCommand.java │ │ │ │ └── LaunchHandler.java │ │ │ ├── path/ │ │ │ │ ├── AbstractPathNode.java │ │ │ │ ├── AnnotationPathNode.java │ │ │ │ ├── BundlePathNode.java │ │ │ │ ├── CatchPathNode.java │ │ │ │ ├── ClassMemberPathNode.java │ │ │ │ ├── ClassPathNode.java │ │ │ │ ├── DirectoryPathNode.java │ │ │ │ ├── EmbeddedResourceContainerPathNode.java │ │ │ │ ├── FilePathNode.java │ │ │ │ ├── IncompletePathException.java │ │ │ │ ├── InnerClassPathNode.java │ │ │ │ ├── InstructionPathNode.java │ │ │ │ ├── LineNumberPathNode.java │ │ │ │ ├── LocalVariablePathNode.java │ │ │ │ ├── PathNode.java │ │ │ │ ├── PathNodes.java │ │ │ │ ├── ResourcePathNode.java │ │ │ │ ├── ThrowsPathNode.java │ │ │ │ └── WorkspacePathNode.java │ │ │ ├── plugin/ │ │ │ │ ├── Plugin.java │ │ │ │ └── PluginInformation.java │ │ │ ├── services/ │ │ │ │ ├── Service.java │ │ │ │ ├── ServiceConfig.java │ │ │ │ ├── assembler/ │ │ │ │ │ ├── AbstractAssemblerPipeline.java │ │ │ │ │ ├── AndroidAssemblerPipeline.java │ │ │ │ │ ├── AndroidAssemblerPipelineConfig.java │ │ │ │ │ ├── AssemblerPipeline.java │ │ │ │ │ ├── AssemblerPipelineConfig.java │ │ │ │ │ ├── AssemblerPipelineGeneralConfig.java │ │ │ │ │ ├── AssemblerPipelineManager.java │ │ │ │ │ ├── ExpressionCompileException.java │ │ │ │ │ ├── ExpressionCompiler.java │ │ │ │ │ ├── ExpressionResult.java │ │ │ │ │ ├── JvmAssemblerPipeline.java │ │ │ │ │ ├── JvmAssemblerPipelineConfig.java │ │ │ │ │ ├── Snippet.java │ │ │ │ │ ├── SnippetListener.java │ │ │ │ │ ├── SnippetManager.java │ │ │ │ │ ├── SnippetManagerConfig.java │ │ │ │ │ └── WorkspaceFieldValueLookup.java │ │ │ │ ├── attach/ │ │ │ │ │ ├── AttachManager.java │ │ │ │ │ ├── AttachManagerConfig.java │ │ │ │ │ ├── BasicAttachManager.java │ │ │ │ │ ├── JmxBeanServerConnection.java │ │ │ │ │ ├── NamedMBeanInfo.java │ │ │ │ │ └── PostScanListener.java │ │ │ │ ├── callgraph/ │ │ │ │ │ ├── CachedLinkResolver.java │ │ │ │ │ ├── CallGraph.java │ │ │ │ │ ├── CallGraphConfig.java │ │ │ │ │ ├── CallGraphService.java │ │ │ │ │ ├── ClassLookup.java │ │ │ │ │ ├── ClassMethodsContainer.java │ │ │ │ │ ├── LinkedClass.java │ │ │ │ │ ├── MethodRef.java │ │ │ │ │ └── MethodVertex.java │ │ │ │ ├── comment/ │ │ │ │ │ ├── ClassComments.java │ │ │ │ │ ├── CommentContainerListener.java │ │ │ │ │ ├── CommentInsertingVisitor.java │ │ │ │ │ ├── CommentKey.java │ │ │ │ │ ├── CommentManager.java │ │ │ │ │ ├── CommentManagerConfig.java │ │ │ │ │ ├── CommentUpdateListener.java │ │ │ │ │ ├── DelegatingClassComments.java │ │ │ │ │ ├── DelegatingWorkspaceComments.java │ │ │ │ │ ├── PersistClassComments.java │ │ │ │ │ ├── PersistWorkspaceComments.java │ │ │ │ │ └── WorkspaceComments.java │ │ │ │ ├── compile/ │ │ │ │ │ ├── CompileMap.java │ │ │ │ │ ├── CompilerDiagnostic.java │ │ │ │ │ ├── CompilerResult.java │ │ │ │ │ ├── ForwardingListener.java │ │ │ │ │ ├── JavacArguments.java │ │ │ │ │ ├── JavacArgumentsBuilder.java │ │ │ │ │ ├── JavacCompiler.java │ │ │ │ │ ├── JavacCompilerConfig.java │ │ │ │ │ ├── JavacListener.java │ │ │ │ │ ├── ResourceVirtualJavaFileObject.java │ │ │ │ │ ├── VirtualFileManager.java │ │ │ │ │ ├── VirtualJavaFileObject.java │ │ │ │ │ ├── VirtualUnitMap.java │ │ │ │ │ └── stub/ │ │ │ │ │ ├── ClassStubGenerator.java │ │ │ │ │ ├── ExpressionHostingClassStubGenerator.java │ │ │ │ │ └── InnerClassStubGenerator.java │ │ │ │ ├── config/ │ │ │ │ │ ├── ConfigManager.java │ │ │ │ │ ├── ConfigManagerConfig.java │ │ │ │ │ └── ManagedConfigListener.java │ │ │ │ ├── decompile/ │ │ │ │ │ ├── AbstractAndroidDecompiler.java │ │ │ │ │ ├── AbstractDecompiler.java │ │ │ │ │ ├── AbstractJvmDecompiler.java │ │ │ │ │ ├── AndroidDecompiler.java │ │ │ │ │ ├── BaseDecompilerConfig.java │ │ │ │ │ ├── DecompileResult.java │ │ │ │ │ ├── Decompiler.java │ │ │ │ │ ├── DecompilerConfig.java │ │ │ │ │ ├── DecompilerManager.java │ │ │ │ │ ├── DecompilerManagerConfig.java │ │ │ │ │ ├── JvmDecompiler.java │ │ │ │ │ ├── NoopAndroidDecompiler.java │ │ │ │ │ ├── NoopDecompilerConfig.java │ │ │ │ │ ├── NoopJvmDecompiler.java │ │ │ │ │ ├── cfr/ │ │ │ │ │ │ ├── CfrConfig.java │ │ │ │ │ │ ├── CfrDecompiler.java │ │ │ │ │ │ ├── ClassSource.java │ │ │ │ │ │ └── SinkFactoryImpl.java │ │ │ │ │ ├── fallback/ │ │ │ │ │ │ ├── FallbackConfig.java │ │ │ │ │ │ ├── FallbackDecompiler.java │ │ │ │ │ │ └── print/ │ │ │ │ │ │ ├── ClassPrinter.java │ │ │ │ │ │ ├── MethodPrinter.java │ │ │ │ │ │ ├── PrintUtils.java │ │ │ │ │ │ └── Printer.java │ │ │ │ │ ├── filter/ │ │ │ │ │ │ ├── JvmBytecodeFilter.java │ │ │ │ │ │ └── OutputTextFilter.java │ │ │ │ │ ├── procyon/ │ │ │ │ │ │ ├── ProcyonConfig.java │ │ │ │ │ │ ├── ProcyonDecompiler.java │ │ │ │ │ │ └── WorkspaceTypeLoader.java │ │ │ │ │ └── vineflower/ │ │ │ │ │ ├── BaseSource.java │ │ │ │ │ ├── ClassSource.java │ │ │ │ │ ├── DecompiledOutputSink.java │ │ │ │ │ ├── DummyResultSaver.java │ │ │ │ │ ├── LibrarySource.java │ │ │ │ │ ├── VineflowerConfig.java │ │ │ │ │ ├── VineflowerDecompiler.java │ │ │ │ │ ├── VineflowerLogger.java │ │ │ │ │ └── WorkspaceEntriesCache.java │ │ │ │ ├── deobfuscation/ │ │ │ │ │ └── transform/ │ │ │ │ │ ├── generic/ │ │ │ │ │ │ ├── CallResultInliningTransformer.java │ │ │ │ │ │ ├── CycleClassRemovingTransformer.java │ │ │ │ │ │ ├── DeadCodeRemovingTransformer.java │ │ │ │ │ │ ├── DuplicateAnnotationRemovingTransformer.java │ │ │ │ │ │ ├── DuplicateCatchMergingTransformer.java │ │ │ │ │ │ ├── EnumNameRestorationTransformer.java │ │ │ │ │ │ ├── ExceptionCollectionTransformer.java │ │ │ │ │ │ ├── FrameRemovingTransformer.java │ │ │ │ │ │ ├── GotoInliningTransformer.java │ │ │ │ │ │ ├── IllegalAnnotationRemovingTransformer.java │ │ │ │ │ │ ├── IllegalNameMappingTransformer.java │ │ │ │ │ │ ├── IllegalSignatureRemovingTransformer.java │ │ │ │ │ │ ├── IllegalVarargsRemovingTransformer.java │ │ │ │ │ │ ├── KotlinMetadataCollectionTransformer.java │ │ │ │ │ │ ├── KotlinNameRestorationTransformer.java │ │ │ │ │ │ ├── LongAnnotationRemovingTransformer.java │ │ │ │ │ │ ├── LongExceptionRemovingTransformer.java │ │ │ │ │ │ ├── OpaqueConstantFoldingTransformer.java │ │ │ │ │ │ ├── OpaquePredicateFoldingTransformer.java │ │ │ │ │ │ ├── RedundantTryCatchRemovingTransformer.java │ │ │ │ │ │ ├── SourceNameRestorationTransformer.java │ │ │ │ │ │ ├── StaticValueCollectionTransformer.java │ │ │ │ │ │ ├── StaticValueInliningTransformer.java │ │ │ │ │ │ ├── UnknownAttributeRemovingTransformer.java │ │ │ │ │ │ ├── VariableFoldingTransformer.java │ │ │ │ │ │ └── VariableTableNormalizingTransformer.java │ │ │ │ │ └── specific/ │ │ │ │ │ └── DashOpaqueSeedFoldingTransformer.java │ │ │ │ ├── file/ │ │ │ │ │ └── RecafDirectoriesConfig.java │ │ │ │ ├── inheritance/ │ │ │ │ │ ├── ClassPathNodeProvider.java │ │ │ │ │ ├── InheritanceGraph.java │ │ │ │ │ ├── InheritanceGraphService.java │ │ │ │ │ ├── InheritanceGraphServiceConfig.java │ │ │ │ │ └── InheritanceVertex.java │ │ │ │ ├── json/ │ │ │ │ │ ├── GsonProvider.java │ │ │ │ │ └── GsonProviderConfig.java │ │ │ │ ├── mapping/ │ │ │ │ │ ├── BasicMappingsRemapper.java │ │ │ │ │ ├── IntermediateMappings.java │ │ │ │ │ ├── MappingApplicationListener.java │ │ │ │ │ ├── MappingApplier.java │ │ │ │ │ ├── MappingApplierConfig.java │ │ │ │ │ ├── MappingApplierService.java │ │ │ │ │ ├── MappingListeners.java │ │ │ │ │ ├── MappingListenersConfig.java │ │ │ │ │ ├── MappingResults.java │ │ │ │ │ ├── Mappings.java │ │ │ │ │ ├── MappingsAdapter.java │ │ │ │ │ ├── UniqueKeyMappings.java │ │ │ │ │ ├── WorkspaceBackedRemapper.java │ │ │ │ │ ├── WorkspaceClassRemapper.java │ │ │ │ │ ├── aggregate/ │ │ │ │ │ │ ├── AggregateMappingManager.java │ │ │ │ │ │ ├── AggregateMappingManagerConfig.java │ │ │ │ │ │ ├── AggregatedMappings.java │ │ │ │ │ │ └── AggregatedMappingsListener.java │ │ │ │ │ ├── data/ │ │ │ │ │ │ ├── AbstractMappingKey.java │ │ │ │ │ │ ├── ClassMapping.java │ │ │ │ │ │ ├── ClassMappingKey.java │ │ │ │ │ │ ├── FieldMapping.java │ │ │ │ │ │ ├── FieldMappingKey.java │ │ │ │ │ │ ├── MappingKey.java │ │ │ │ │ │ ├── MemberMapping.java │ │ │ │ │ │ ├── MethodMapping.java │ │ │ │ │ │ ├── MethodMappingKey.java │ │ │ │ │ │ ├── VariableMapping.java │ │ │ │ │ │ └── VariableMappingKey.java │ │ │ │ │ ├── format/ │ │ │ │ │ │ ├── AbstractMappingFileFormat.java │ │ │ │ │ │ ├── EnigmaMappings.java │ │ │ │ │ │ ├── InvalidMappingException.java │ │ │ │ │ │ ├── JadxMappings.java │ │ │ │ │ │ ├── MappingFileFormat.java │ │ │ │ │ │ ├── MappingFormatManager.java │ │ │ │ │ │ ├── MappingFormatManagerConfig.java │ │ │ │ │ │ ├── MappingTreeReader.java │ │ │ │ │ │ ├── ProguardMappings.java │ │ │ │ │ │ ├── SimpleMappings.java │ │ │ │ │ │ ├── SrgMappings.java │ │ │ │ │ │ ├── TinyV1Mappings.java │ │ │ │ │ │ └── TinyV2Mappings.java │ │ │ │ │ └── gen/ │ │ │ │ │ ├── MappingGenerator.java │ │ │ │ │ ├── MappingGeneratorConfig.java │ │ │ │ │ ├── filter/ │ │ │ │ │ │ ├── ExcludeClassesFilter.java │ │ │ │ │ │ ├── ExcludeEnumMethodsFilter.java │ │ │ │ │ │ ├── ExcludeExistingMappedFilter.java │ │ │ │ │ │ ├── ExcludeModifiersNameFilter.java │ │ │ │ │ │ ├── ExcludeNameFilter.java │ │ │ │ │ │ ├── IncludeClassesFilter.java │ │ │ │ │ │ ├── IncludeKeywordNameFilter.java │ │ │ │ │ │ ├── IncludeLongNameFilter.java │ │ │ │ │ │ ├── IncludeModifiersNameFilter.java │ │ │ │ │ │ ├── IncludeNameFilter.java │ │ │ │ │ │ ├── IncludeNonAsciiNameFilter.java │ │ │ │ │ │ ├── IncludeNonJavaIdentifierNameFilter.java │ │ │ │ │ │ ├── IncludeWhitespaceNameFilter.java │ │ │ │ │ │ └── NameGeneratorFilter.java │ │ │ │ │ └── naming/ │ │ │ │ │ ├── AbstractNameGeneratorProvider.java │ │ │ │ │ ├── AlphabetNameGenerator.java │ │ │ │ │ ├── AlphabetNameGeneratorProvider.java │ │ │ │ │ ├── DeconflictingNameGenerator.java │ │ │ │ │ ├── IncrementingNameGenerator.java │ │ │ │ │ ├── IncrementingNameGeneratorProvider.java │ │ │ │ │ ├── NameGenerator.java │ │ │ │ │ ├── NameGeneratorProvider.java │ │ │ │ │ ├── NameGeneratorProviders.java │ │ │ │ │ └── NameGeneratorProvidersConfig.java │ │ │ │ ├── phantom/ │ │ │ │ │ ├── GeneratedPhantomWorkspaceResource.java │ │ │ │ │ ├── JPhantomGenerator.java │ │ │ │ │ ├── JPhantomGeneratorConfig.java │ │ │ │ │ ├── PhantomGenerationException.java │ │ │ │ │ └── PhantomGenerator.java │ │ │ │ ├── plugin/ │ │ │ │ │ ├── AllocationException.java │ │ │ │ │ ├── BasicPluginManager.java │ │ │ │ │ ├── CdiClassAllocator.java │ │ │ │ │ ├── ClassAllocator.java │ │ │ │ │ ├── LoadedPlugin.java │ │ │ │ │ ├── PluginClassLoader.java │ │ │ │ │ ├── PluginClassLoaderImpl.java │ │ │ │ │ ├── PluginContainer.java │ │ │ │ │ ├── PluginContainerImpl.java │ │ │ │ │ ├── PluginException.java │ │ │ │ │ ├── PluginGraph.java │ │ │ │ │ ├── PluginId.java │ │ │ │ │ ├── PluginInfo.java │ │ │ │ │ ├── PluginLoader.java │ │ │ │ │ ├── PluginManager.java │ │ │ │ │ ├── PluginManagerConfig.java │ │ │ │ │ ├── PluginSource.java │ │ │ │ │ ├── PluginUnloader.java │ │ │ │ │ ├── PreparedPlugin.java │ │ │ │ │ ├── discovery/ │ │ │ │ │ │ ├── DirectoryPluginDiscoverer.java │ │ │ │ │ │ ├── DiscoveredPluginSource.java │ │ │ │ │ │ ├── PathPluginDiscoverer.java │ │ │ │ │ │ └── PluginDiscoverer.java │ │ │ │ │ └── zip/ │ │ │ │ │ ├── ZipArchiveView.java │ │ │ │ │ ├── ZipPluginLoader.java │ │ │ │ │ ├── ZipPreparedPlugin.java │ │ │ │ │ └── ZipSource.java │ │ │ │ ├── script/ │ │ │ │ │ ├── GenerateResult.java │ │ │ │ │ ├── JavacScriptEngine.java │ │ │ │ │ ├── ScriptEngine.java │ │ │ │ │ ├── ScriptEngineConfig.java │ │ │ │ │ ├── ScriptFile.java │ │ │ │ │ ├── ScriptManager.java │ │ │ │ │ ├── ScriptManagerConfig.java │ │ │ │ │ └── ScriptResult.java │ │ │ │ ├── search/ │ │ │ │ │ ├── AndroidClassSearchVisitor.java │ │ │ │ │ ├── CancellableSearchFeedback.java │ │ │ │ │ ├── FileSearchVisitor.java │ │ │ │ │ ├── JvmClassSearchVisitor.java │ │ │ │ │ ├── ResultSink.java │ │ │ │ │ ├── SearchFeedback.java │ │ │ │ │ ├── SearchService.java │ │ │ │ │ ├── SearchServiceConfig.java │ │ │ │ │ ├── SearchVisitor.java │ │ │ │ │ ├── match/ │ │ │ │ │ │ ├── BiNumberMatcher.java │ │ │ │ │ │ ├── BiStringMatcher.java │ │ │ │ │ │ ├── MultiNumberMatcher.java │ │ │ │ │ │ ├── MultiStringMatcher.java │ │ │ │ │ │ ├── NumberPredicate.java │ │ │ │ │ │ ├── NumberPredicateProvider.java │ │ │ │ │ │ ├── RangeNumberMatcher.java │ │ │ │ │ │ ├── StringPredicate.java │ │ │ │ │ │ └── StringPredicateProvider.java │ │ │ │ │ ├── query/ │ │ │ │ │ │ ├── AbstractValueQuery.java │ │ │ │ │ │ ├── AndroidClassQuery.java │ │ │ │ │ │ ├── DeclarationQuery.java │ │ │ │ │ │ ├── FileQuery.java │ │ │ │ │ │ ├── InstructionQuery.java │ │ │ │ │ │ ├── JvmClassQuery.java │ │ │ │ │ │ ├── NumberQuery.java │ │ │ │ │ │ ├── Query.java │ │ │ │ │ │ ├── ReferenceQuery.java │ │ │ │ │ │ └── StringQuery.java │ │ │ │ │ └── result/ │ │ │ │ │ ├── ClassReference.java │ │ │ │ │ ├── ClassReferenceResult.java │ │ │ │ │ ├── MemberReference.java │ │ │ │ │ ├── MemberReferenceResult.java │ │ │ │ │ ├── NumberResult.java │ │ │ │ │ ├── Result.java │ │ │ │ │ ├── Results.java │ │ │ │ │ └── StringResult.java │ │ │ │ ├── source/ │ │ │ │ │ ├── AstMapper.java │ │ │ │ │ ├── AstResolveResult.java │ │ │ │ │ ├── AstService.java │ │ │ │ │ ├── AstServiceConfig.java │ │ │ │ │ └── ResolverAdapter.java │ │ │ │ ├── text/ │ │ │ │ │ └── TextFormatConfig.java │ │ │ │ ├── transform/ │ │ │ │ │ ├── CancellableTransformationFeedback.java │ │ │ │ │ ├── ClassTransformer.java │ │ │ │ │ ├── JvmClassTransformer.java │ │ │ │ │ ├── JvmTransformResult.java │ │ │ │ │ ├── JvmTransformerContext.java │ │ │ │ │ ├── TransformResult.java │ │ │ │ │ ├── TransformationApplier.java │ │ │ │ │ ├── TransformationApplierConfig.java │ │ │ │ │ ├── TransformationApplierService.java │ │ │ │ │ ├── TransformationException.java │ │ │ │ │ ├── TransformationFeedback.java │ │ │ │ │ ├── TransformationManager.java │ │ │ │ │ └── TransformationManagerConfig.java │ │ │ │ ├── tutorial/ │ │ │ │ │ ├── TutorialConfig.java │ │ │ │ │ ├── TutorialWorkspace.java │ │ │ │ │ └── TutorialWorkspaceResource.java │ │ │ │ └── workspace/ │ │ │ │ ├── BasicWorkspaceManager.java │ │ │ │ ├── WorkspaceCloseCondition.java │ │ │ │ ├── WorkspaceCloseListener.java │ │ │ │ ├── WorkspaceManager.java │ │ │ │ ├── WorkspaceManagerConfig.java │ │ │ │ ├── WorkspaceOpenListener.java │ │ │ │ ├── WorkspaceProcessingConfig.java │ │ │ │ ├── WorkspaceProcessingService.java │ │ │ │ ├── WorkspaceProcessor.java │ │ │ │ ├── io/ │ │ │ │ │ ├── BasicClassPatcher.java │ │ │ │ │ ├── BasicInfoImporter.java │ │ │ │ │ ├── BasicResourceImporter.java │ │ │ │ │ ├── ByteArrayWorkspaceExportConsumer.java │ │ │ │ │ ├── ClassPatcher.java │ │ │ │ │ ├── InfoImporter.java │ │ │ │ │ ├── InfoImporterConfig.java │ │ │ │ │ ├── PathWorkspaceExportConsumer.java │ │ │ │ │ ├── ResourceImporter.java │ │ │ │ │ ├── ResourceImporterConfig.java │ │ │ │ │ ├── WorkspaceCompressType.java │ │ │ │ │ ├── WorkspaceExportConsumer.java │ │ │ │ │ ├── WorkspaceExportOptions.java │ │ │ │ │ ├── WorkspaceExporter.java │ │ │ │ │ └── WorkspaceOutputType.java │ │ │ │ ├── patch/ │ │ │ │ │ ├── PatchApplier.java │ │ │ │ │ ├── PatchFeedback.java │ │ │ │ │ ├── PatchGenerationException.java │ │ │ │ │ ├── PatchProvider.java │ │ │ │ │ ├── PatchSerialization.java │ │ │ │ │ ├── ResourcePatchApplierConfig.java │ │ │ │ │ ├── ResourcePatchProviderConfig.java │ │ │ │ │ └── model/ │ │ │ │ │ ├── JvmAssemblerPatch.java │ │ │ │ │ ├── RemovePath.java │ │ │ │ │ ├── TextFilePatch.java │ │ │ │ │ └── WorkspacePatch.java │ │ │ │ └── processors/ │ │ │ │ └── ThrowablePropertyAssigningProcessor.java │ │ │ ├── util/ │ │ │ │ ├── AccessFlag.java │ │ │ │ ├── AccessPatcher.java │ │ │ │ ├── AsmInsnUtil.java │ │ │ │ ├── BlwUtil.java │ │ │ │ ├── ByteHeaderUtil.java │ │ │ │ ├── CancelSignal.java │ │ │ │ ├── ClassDefiner.java │ │ │ │ ├── ClassLoaderInternals.java │ │ │ │ ├── ClasspathUtil.java │ │ │ │ ├── CollectionUtils.java │ │ │ │ ├── DesktopUtil.java │ │ │ │ ├── DevDetection.java │ │ │ │ ├── EscapeUtil.java │ │ │ │ ├── ExcludeFromJacocoGeneratedReport.java │ │ │ │ ├── Handles.java │ │ │ │ ├── IOUtil.java │ │ │ │ ├── InternalPath.java │ │ │ │ ├── JavaDowngraderUtil.java │ │ │ │ ├── JavaVersion.java │ │ │ │ ├── JdkValidation.java │ │ │ │ ├── Keywords.java │ │ │ │ ├── MemoizedFunctions.java │ │ │ │ ├── ModulesIOUtil.java │ │ │ │ ├── MultiMap.java │ │ │ │ ├── MultiMapBuilder.java │ │ │ │ ├── NumberUtil.java │ │ │ │ ├── PlatformType.java │ │ │ │ ├── ReflectUtil.java │ │ │ │ ├── RegexUtil.java │ │ │ │ ├── ResourceUtil.java │ │ │ │ ├── SelfReferenceUtil.java │ │ │ │ ├── ShortcutUtil.java │ │ │ │ ├── Streams.java │ │ │ │ ├── StringDecodingResult.java │ │ │ │ ├── StringDiff.java │ │ │ │ ├── StringUtil.java │ │ │ │ ├── TestEnvironment.java │ │ │ │ ├── Types.java │ │ │ │ ├── UnsafeIO.java │ │ │ │ ├── UnsafeUtil.java │ │ │ │ ├── ZipCreationUtils.java │ │ │ │ ├── analysis/ │ │ │ │ │ ├── Branching.java │ │ │ │ │ ├── Nullness.java │ │ │ │ │ ├── ReAnalyzer.java │ │ │ │ │ ├── ReFrame.java │ │ │ │ │ ├── ReInterpreter.java │ │ │ │ │ ├── eval/ │ │ │ │ │ │ ├── EvaluationException.java │ │ │ │ │ │ ├── EvaluationFailureResult.java │ │ │ │ │ │ ├── EvaluationResult.java │ │ │ │ │ │ ├── EvaluationThrowsResult.java │ │ │ │ │ │ ├── EvaluationYieldResult.java │ │ │ │ │ │ ├── Evaluator.java │ │ │ │ │ │ ├── FieldCache.java │ │ │ │ │ │ ├── FieldCacheManager.java │ │ │ │ │ │ ├── InstanceFactory.java │ │ │ │ │ │ ├── InstanceMapper.java │ │ │ │ │ │ ├── InstancedObjectValue.java │ │ │ │ │ │ └── MethodInvokeHandler.java │ │ │ │ │ ├── gen/ │ │ │ │ │ │ ├── GenUtils.java │ │ │ │ │ │ ├── InstanceMapperGenerator.java │ │ │ │ │ │ ├── InstanceMethodInvokeHandlerGenerator.java │ │ │ │ │ │ ├── InstanceStaticMapperGenerator.java │ │ │ │ │ │ └── LookupGenerator.java │ │ │ │ │ ├── lookup/ │ │ │ │ │ │ ├── BasicGetStaticLookup.java │ │ │ │ │ │ ├── BasicInvokeStaticLookup.java │ │ │ │ │ │ ├── BasicInvokeVirtualLookup.java │ │ │ │ │ │ ├── BasicLookupUtils.java │ │ │ │ │ │ ├── GetFieldLookup.java │ │ │ │ │ │ ├── GetStaticLookup.java │ │ │ │ │ │ ├── InvokeStaticLookup.java │ │ │ │ │ │ └── InvokeVirtualLookup.java │ │ │ │ │ └── value/ │ │ │ │ │ ├── ArrayValue.java │ │ │ │ │ ├── DoubleValue.java │ │ │ │ │ ├── FloatValue.java │ │ │ │ │ ├── IllegalValueException.java │ │ │ │ │ ├── IntValue.java │ │ │ │ │ ├── LongValue.java │ │ │ │ │ ├── ObjectValue.java │ │ │ │ │ ├── ReValue.java │ │ │ │ │ ├── StringValue.java │ │ │ │ │ ├── UninitializedValue.java │ │ │ │ │ └── impl/ │ │ │ │ │ ├── ArrayValueImpl.java │ │ │ │ │ ├── BoxedBooleanValueImpl.java │ │ │ │ │ ├── BoxedByteValueImpl.java │ │ │ │ │ ├── BoxedCharacterValueImpl.java │ │ │ │ │ ├── BoxedDoubleValueImpl.java │ │ │ │ │ ├── BoxedFloatValueImpl.java │ │ │ │ │ ├── BoxedIntegerValueImpl.java │ │ │ │ │ ├── BoxedLongValueImpl.java │ │ │ │ │ ├── BoxedShortValueImpl.java │ │ │ │ │ ├── DoubleValueImpl.java │ │ │ │ │ ├── FloatValueImpl.java │ │ │ │ │ ├── IntValueImpl.java │ │ │ │ │ ├── LongValueImpl.java │ │ │ │ │ ├── ObjectValueBoxImpl.java │ │ │ │ │ ├── ObjectValueImpl.java │ │ │ │ │ ├── StringValueImpl.java │ │ │ │ │ └── UninitializedValueImpl.java │ │ │ │ ├── android/ │ │ │ │ │ ├── AndroidRes.java │ │ │ │ │ ├── AndroidXmlUtil.java │ │ │ │ │ └── DexIOUtil.java │ │ │ │ ├── io/ │ │ │ │ │ ├── ByteArraySource.java │ │ │ │ │ ├── ByteBufferSource.java │ │ │ │ │ ├── ByteSource.java │ │ │ │ │ ├── ByteSourceConsumer.java │ │ │ │ │ ├── ByteSourceElement.java │ │ │ │ │ ├── ByteSources.java │ │ │ │ │ ├── LocalFileHeaderSource.java │ │ │ │ │ ├── MemorySegmentDataSource.java │ │ │ │ │ └── PathByteSource.java │ │ │ │ ├── kotlin/ │ │ │ │ │ ├── KotlinMetadata.java │ │ │ │ │ └── model/ │ │ │ │ │ ├── KtClass.java │ │ │ │ │ ├── KtClassKind.java │ │ │ │ │ ├── KtConstructor.java │ │ │ │ │ ├── KtElement.java │ │ │ │ │ ├── KtFunction.java │ │ │ │ │ ├── KtNullability.java │ │ │ │ │ ├── KtParameter.java │ │ │ │ │ ├── KtProperty.java │ │ │ │ │ ├── KtType.java │ │ │ │ │ └── KtVariable.java │ │ │ │ ├── threading/ │ │ │ │ │ ├── Batch.java │ │ │ │ │ ├── CountDown.java │ │ │ │ │ ├── DirectBatch.java │ │ │ │ │ ├── ExecutorServiceDelegate.java │ │ │ │ │ ├── PhasingExecutorService.java │ │ │ │ │ ├── ScheduledExecutorServiceDelegate.java │ │ │ │ │ ├── ThreadPoolFactory.java │ │ │ │ │ └── ThreadUtil.java │ │ │ │ └── visitors/ │ │ │ │ ├── AnnotationArrayVisitor.java │ │ │ │ ├── BogusNameRemovingVisitor.java │ │ │ │ ├── ClassAnnotationInsertingVisitor.java │ │ │ │ ├── ClassAnnotationRemovingVisitor.java │ │ │ │ ├── ClassHollowingVisitor.java │ │ │ │ ├── DuplicateAnnotationRemovingVisitor.java │ │ │ │ ├── FieldAnnotationInsertingVisitor.java │ │ │ │ ├── FieldAnnotationRemovingVisitor.java │ │ │ │ ├── FieldInsertingVisitor.java │ │ │ │ ├── FieldPredicate.java │ │ │ │ ├── FieldReplacingVisitor.java │ │ │ │ ├── FrameSkippingVisitor.java │ │ │ │ ├── IllegalAnnotationRemovingVisitor.java │ │ │ │ ├── IllegalSignatureRemovingVisitor.java │ │ │ │ ├── IllegalVarargsRemovingVisitor.java │ │ │ │ ├── IndexCountingMethodVisitor.java │ │ │ │ ├── KotlinMetadataVisitor.java │ │ │ │ ├── LongAnnotationRemovingVisitor.java │ │ │ │ ├── LongExceptionRemovingVisitor.java │ │ │ │ ├── MemberCopyingVisitor.java │ │ │ │ ├── MemberFilteringVisitor.java │ │ │ │ ├── MemberPredicate.java │ │ │ │ ├── MemberRemovingVisitor.java │ │ │ │ ├── MemberStubAddingVisitor.java │ │ │ │ ├── MethodAnnotationInsertingVisitor.java │ │ │ │ ├── MethodAnnotationRemovingVisitor.java │ │ │ │ ├── MethodInsertingVisitor.java │ │ │ │ ├── MethodNoopingVisitor.java │ │ │ │ ├── MethodPredicate.java │ │ │ │ ├── MethodReplacingVisitor.java │ │ │ │ ├── MethodVariableRemovingVisitor.java │ │ │ │ ├── SignatureRemovingVisitor.java │ │ │ │ ├── SkippingAnnotationVisitor.java │ │ │ │ ├── SkippingClassVisitor.java │ │ │ │ ├── SkippingFieldVisitor.java │ │ │ │ ├── SkippingMethodVisitor.java │ │ │ │ ├── SyntheticRemovingVisitor.java │ │ │ │ ├── TypeVisitor.java │ │ │ │ ├── UnknownAttributeRemovingVisitor.java │ │ │ │ ├── VariableRemovingClassVisitor.java │ │ │ │ ├── VariableRemovingMethodVisitor.java │ │ │ │ └── WorkspaceClassWriter.java │ │ │ └── workspace/ │ │ │ └── model/ │ │ │ ├── BasicWorkspace.java │ │ │ ├── EmptyWorkspace.java │ │ │ ├── Workspace.java │ │ │ ├── WorkspaceModificationListener.java │ │ │ ├── bundle/ │ │ │ │ ├── AndroidClassBundle.java │ │ │ │ ├── BasicAndroidClassBundle.java │ │ │ │ ├── BasicBundle.java │ │ │ │ ├── BasicFileBundle.java │ │ │ │ ├── BasicJvmClassBundle.java │ │ │ │ ├── BasicVersionedJvmClassBundle.java │ │ │ │ ├── Bundle.java │ │ │ │ ├── BundleListener.java │ │ │ │ ├── ClassBundle.java │ │ │ │ ├── FileBundle.java │ │ │ │ ├── JvmClassBundle.java │ │ │ │ └── VersionedJvmClassBundle.java │ │ │ └── resource/ │ │ │ ├── AgentServerRemoteVmResource.java │ │ │ ├── AndroidApiResource.java │ │ │ ├── BasicWorkspaceDirectoryResource.java │ │ │ ├── BasicWorkspaceFileResource.java │ │ │ ├── BasicWorkspaceResource.java │ │ │ ├── ResourceAndroidClassListener.java │ │ │ ├── ResourceFileListener.java │ │ │ ├── ResourceJvmClassListener.java │ │ │ ├── RuntimeWorkspaceResource.java │ │ │ ├── WorkspaceDirectoryResource.java │ │ │ ├── WorkspaceDirectoryResourceBuilder.java │ │ │ ├── WorkspaceFileResource.java │ │ │ ├── WorkspaceFileResourceBuilder.java │ │ │ ├── WorkspaceRemoteVmResource.java │ │ │ ├── WorkspaceResource.java │ │ │ └── WorkspaceResourceBuilder.java │ │ └── resources/ │ │ ├── android/ │ │ │ ├── api-outline-30.jar │ │ │ ├── attrs.json │ │ │ └── res-map.txt │ │ └── logback.xml │ ├── test/ │ │ ├── java/ │ │ │ └── software/ │ │ │ └── coley/ │ │ │ └── recaf/ │ │ │ ├── BootstrapTest.java │ │ │ ├── info/ │ │ │ │ ├── ClassInfoTest.java │ │ │ │ ├── JvmClassInfoTest.java │ │ │ │ ├── annotation/ │ │ │ │ │ ├── AnnotatedTest.java │ │ │ │ │ ├── AnnotationInfoTest.java │ │ │ │ │ └── TypeAnnotationInfoTest.java │ │ │ │ └── member/ │ │ │ │ ├── FieldMemberTest.java │ │ │ │ └── MethodMemberTest.java │ │ │ ├── path/ │ │ │ │ └── PathNodeTest.java │ │ │ ├── services/ │ │ │ │ ├── assembler/ │ │ │ │ │ └── ExpressionCompilerTest.java │ │ │ │ ├── callgraph/ │ │ │ │ │ └── CallGraphTest.java │ │ │ │ ├── comment/ │ │ │ │ │ └── CommentManagerTest.java │ │ │ │ ├── compile/ │ │ │ │ │ └── JavacCompilerTest.java │ │ │ │ ├── decompile/ │ │ │ │ │ ├── DecompileManagerTest.java │ │ │ │ │ └── FallbackDecompilerTest.java │ │ │ │ ├── deobfuscation/ │ │ │ │ │ ├── BaseDeobfuscationTest.java │ │ │ │ │ ├── CycleRemovingTest.java │ │ │ │ │ ├── EvaluatorTest.java │ │ │ │ │ ├── FoldingDeobfuscationTest.java │ │ │ │ │ ├── IllegalAttributeDeobfuscationTest.java │ │ │ │ │ ├── MiscDeobfuscationTest.java │ │ │ │ │ ├── RegressionDeobfuscationTest.java │ │ │ │ │ ├── StaticValueInliningTest.java │ │ │ │ │ └── TryCatchDeobfuscationTest.java │ │ │ │ ├── inheritance/ │ │ │ │ │ ├── InheritanceAndRenamingTest.java │ │ │ │ │ └── InheritanceGraphTest.java │ │ │ │ ├── json/ │ │ │ │ │ └── GsonProviderTest.java │ │ │ │ ├── mapping/ │ │ │ │ │ ├── MappingApplierTest.java │ │ │ │ │ ├── aggregate/ │ │ │ │ │ │ ├── AggregateMappingManagerTest.java │ │ │ │ │ │ └── AggregateMappingsTest.java │ │ │ │ │ ├── format/ │ │ │ │ │ │ ├── MappingImplementationTest.java │ │ │ │ │ │ └── MappingIntermediateTest.java │ │ │ │ │ └── gen/ │ │ │ │ │ └── MappingGeneratorTest.java │ │ │ │ ├── phantom/ │ │ │ │ │ └── PhantomGeneratorTest.java │ │ │ │ ├── plugin/ │ │ │ │ │ └── PluginManagerTest.java │ │ │ │ ├── script/ │ │ │ │ │ └── JavacScriptEngineTest.java │ │ │ │ ├── search/ │ │ │ │ │ └── SearchServiceTest.java │ │ │ │ ├── source/ │ │ │ │ │ └── AstServiceTest.java │ │ │ │ ├── transform/ │ │ │ │ │ ├── TransformationApplierTest.java │ │ │ │ │ └── TransformationManagerTest.java │ │ │ │ └── workspace/ │ │ │ │ ├── io/ │ │ │ │ │ ├── InfoImporterTest.java │ │ │ │ │ ├── ResourceImporterTest.java │ │ │ │ │ └── WorkspaceExporterTest.java │ │ │ │ └── patch/ │ │ │ │ └── PatchingTest.java │ │ │ ├── util/ │ │ │ │ ├── AccessFlagTest.java │ │ │ │ ├── AsmInsnUtilTest.java │ │ │ │ ├── EscapeUtilTest.java │ │ │ │ ├── NumberUtilTest.java │ │ │ │ ├── StringDiffTest.java │ │ │ │ ├── StringUtilTest.java │ │ │ │ ├── TypesTest.java │ │ │ │ └── android/ │ │ │ │ └── AndroidResConversion.java │ │ │ └── workspace/ │ │ │ └── model/ │ │ │ └── WorkspaceModelTest.java │ │ └── resources/ │ │ └── android/ │ │ ├── attrs.xml │ │ └── attrs_manifest.xml │ └── testFixtures/ │ ├── java/ │ │ └── software/ │ │ └── coley/ │ │ └── recaf/ │ │ └── test/ │ │ ├── TestBase.java │ │ ├── TestClassUtils.java │ │ ├── TestConfigSetup.java │ │ └── dummy/ │ │ ├── AccessibleFields.java │ │ ├── AccessibleMethods.java │ │ ├── AccessibleMethodsChild.java │ │ ├── AnnotationImpl.java │ │ ├── AnonymousLambda.java │ │ ├── ArrayTypeAnno.java │ │ ├── ClassWithAnnotation.java │ │ ├── ClassWithAnonymousInner.java │ │ ├── ClassWithConstructor.java │ │ ├── ClassWithEmbeddedInners.java │ │ ├── ClassWithExceptions.java │ │ ├── ClassWithFieldsAndMethods.java │ │ ├── ClassWithInner.java │ │ ├── ClassWithInnerAndMembers.java │ │ ├── ClassWithInvisAnnotation.java │ │ ├── ClassWithLambda.java │ │ ├── ClassWithMethodReference.java │ │ ├── ClassWithMultipleMethods.java │ │ ├── ClassWithRequiredConstructor.java │ │ ├── ClassWithStaticInit.java │ │ ├── ClassWithToString.java │ │ ├── DiamondA.java │ │ ├── DiamondB.java │ │ ├── DiamondC.java │ │ ├── DummyEmptyMap.java │ │ ├── DummyEnum.java │ │ ├── DummyEnumPrinter.java │ │ ├── DummyRecord.java │ │ ├── HelloWorld.java │ │ ├── Inheritance.java │ │ ├── InvisAnnotationImpl.java │ │ ├── MethodWithTypeAnno.java │ │ ├── MultipleInterfacesClass.java │ │ ├── OverlapCaller.java │ │ ├── OverlapClassAB.java │ │ ├── OverlapInterfaceA.java │ │ ├── OverlapInterfaceB.java │ │ ├── SealedCircle.java │ │ ├── SealedOtherShape.java │ │ ├── SealedShape.java │ │ ├── SealedSquare.java │ │ ├── StringConsumer.java │ │ ├── StringConsumerUser.java │ │ ├── StringList.java │ │ ├── StringListUser.java │ │ ├── StringSupplier.java │ │ ├── TypeAnnotationImpl.java │ │ ├── VariedModifierFields.java │ │ └── VariedModifierMethods.java │ └── resources/ │ ├── lorem-long-ascii.txt │ ├── lorem-long-cn.txt │ ├── lorem-long-ru.txt │ ├── lorem-short-ascii.txt │ ├── lorem-short-cn.txt │ ├── lorem-short-ru.txt │ └── name-prefix-suffix.jar ├── recaf-ui/ │ ├── build.gradle │ └── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── software/ │ │ │ └── coley/ │ │ │ └── recaf/ │ │ │ ├── Main.java │ │ │ ├── RecafApplication.java │ │ │ ├── path/ │ │ │ │ ├── AssemblerPathData.java │ │ │ │ └── AssemblerPathNode.java │ │ │ ├── services/ │ │ │ │ ├── cell/ │ │ │ │ │ ├── CellConfigurationService.java │ │ │ │ │ ├── CellConfigurationServiceConfig.java │ │ │ │ │ ├── context/ │ │ │ │ │ │ ├── AbstractContextMenuProviderFactory.java │ │ │ │ │ │ ├── AnnotationContextMenuAdapter.java │ │ │ │ │ │ ├── AnnotationContextMenuProviderFactory.java │ │ │ │ │ │ ├── AssemblerContextMenuAdapter.java │ │ │ │ │ │ ├── AssemblerContextMenuProviderFactory.java │ │ │ │ │ │ ├── BasicAnnotationContextMenuProviderFactory.java │ │ │ │ │ │ ├── BasicAssemblerContextMenuProviderFactory.java │ │ │ │ │ │ ├── BasicBlacklistingContextSource.java │ │ │ │ │ │ ├── BasicBundleContextMenuProviderFactory.java │ │ │ │ │ │ ├── BasicClassContextMenuProviderFactory.java │ │ │ │ │ │ ├── BasicContextSource.java │ │ │ │ │ │ ├── BasicDirectoryContextMenuProviderFactory.java │ │ │ │ │ │ ├── BasicFieldContextMenuProviderFactory.java │ │ │ │ │ │ ├── BasicFileContextMenuProviderFactory.java │ │ │ │ │ │ ├── BasicInnerClassContextMenuProviderFactory.java │ │ │ │ │ │ ├── BasicMethodContextMenuProviderFactory.java │ │ │ │ │ │ ├── BasicPackageContextMenuProviderFactory.java │ │ │ │ │ │ ├── BasicResourceContextMenuProviderFactory.java │ │ │ │ │ │ ├── BasicWhitelistingContextSource.java │ │ │ │ │ │ ├── BundleContextMenuAdapter.java │ │ │ │ │ │ ├── BundleContextMenuProviderFactory.java │ │ │ │ │ │ ├── ClassContextMenuAdapter.java │ │ │ │ │ │ ├── ClassContextMenuProviderFactory.java │ │ │ │ │ │ ├── ContextMenuAdapter.java │ │ │ │ │ │ ├── ContextMenuProvider.java │ │ │ │ │ │ ├── ContextMenuProviderFactory.java │ │ │ │ │ │ ├── ContextMenuProviderService.java │ │ │ │ │ │ ├── ContextMenuProviderServiceConfig.java │ │ │ │ │ │ ├── ContextSource.java │ │ │ │ │ │ ├── DirectoryContextMenuAdapter.java │ │ │ │ │ │ ├── DirectoryContextMenuProviderFactory.java │ │ │ │ │ │ ├── FieldContextMenuAdapter.java │ │ │ │ │ │ ├── FieldContextMenuProviderFactory.java │ │ │ │ │ │ ├── FileContextMenuAdapter.java │ │ │ │ │ │ ├── FileContextMenuProviderFactory.java │ │ │ │ │ │ ├── InnerClassContextMenuAdapter.java │ │ │ │ │ │ ├── InnerClassContextMenuProviderFactory.java │ │ │ │ │ │ ├── MethodContextMenuAdapter.java │ │ │ │ │ │ ├── MethodContextMenuProviderFactory.java │ │ │ │ │ │ ├── PackageContextMenuAdapter.java │ │ │ │ │ │ ├── PackageContextMenuProviderFactory.java │ │ │ │ │ │ ├── ResourceContextMenuAdapter.java │ │ │ │ │ │ └── ResourceContextMenuProviderFactory.java │ │ │ │ │ ├── icon/ │ │ │ │ │ │ ├── AnnotationIconProviderFactory.java │ │ │ │ │ │ ├── BasicAnnotationIconProviderFactory.java │ │ │ │ │ │ ├── BasicBundleIconProviderFactory.java │ │ │ │ │ │ ├── BasicCatchIconProviderFactory.java │ │ │ │ │ │ ├── BasicClassIconProviderFactory.java │ │ │ │ │ │ ├── BasicDirectoryIconProviderFactory.java │ │ │ │ │ │ ├── BasicFieldIconProviderFactory.java │ │ │ │ │ │ ├── BasicFileIconProviderFactory.java │ │ │ │ │ │ ├── BasicInnerClassIconProviderFactory.java │ │ │ │ │ │ ├── BasicInstructionIconProviderFactory.java │ │ │ │ │ │ ├── BasicMethodIconProviderFactory.java │ │ │ │ │ │ ├── BasicPackageIconProviderFactory.java │ │ │ │ │ │ ├── BasicResourceIconProviderFactory.java │ │ │ │ │ │ ├── BasicThrowsProviderFactory.java │ │ │ │ │ │ ├── BasicVariableIconProviderFactory.java │ │ │ │ │ │ ├── BundleIconProviderFactory.java │ │ │ │ │ │ ├── CatchIconProviderFactory.java │ │ │ │ │ │ ├── ClassIconProviderFactory.java │ │ │ │ │ │ ├── DirectoryIconProviderFactory.java │ │ │ │ │ │ ├── FieldIconProviderFactory.java │ │ │ │ │ │ ├── FileIconProviderFactory.java │ │ │ │ │ │ ├── IconProvider.java │ │ │ │ │ │ ├── IconProviderFactory.java │ │ │ │ │ │ ├── IconProviderService.java │ │ │ │ │ │ ├── IconProviderServiceConfig.java │ │ │ │ │ │ ├── InnerClassIconProviderFactory.java │ │ │ │ │ │ ├── InstructionIconProviderFactory.java │ │ │ │ │ │ ├── MethodIconProviderFactory.java │ │ │ │ │ │ ├── PackageIconProviderFactory.java │ │ │ │ │ │ ├── ResourceIconProviderFactory.java │ │ │ │ │ │ ├── ThrowsIconProviderFactory.java │ │ │ │ │ │ └── VariableIconProviderFactory.java │ │ │ │ │ └── text/ │ │ │ │ │ ├── TextProvider.java │ │ │ │ │ ├── TextProviderFactory.java │ │ │ │ │ ├── TextProviderService.java │ │ │ │ │ └── TextProviderServiceConfig.java │ │ │ │ ├── config/ │ │ │ │ │ ├── ConfigComponentFactory.java │ │ │ │ │ ├── ConfigComponentManager.java │ │ │ │ │ ├── ConfigComponentManagerConfig.java │ │ │ │ │ ├── ConfigIconManager.java │ │ │ │ │ ├── ConfigIconManagerConfig.java │ │ │ │ │ ├── KeyedConfigComponentFactory.java │ │ │ │ │ ├── TypedConfigComponentFactory.java │ │ │ │ │ └── factories/ │ │ │ │ │ ├── AndroidDecompilerComponentFactory.java │ │ │ │ │ ├── BooleanComponentFactory.java │ │ │ │ │ ├── EnumComponentFactory.java │ │ │ │ │ ├── IntegerComponentFactory.java │ │ │ │ │ ├── JvmDecompilerComponentFactory.java │ │ │ │ │ ├── ProcyonLanguageComponentFactory.java │ │ │ │ │ └── StringComponentFactory.java │ │ │ │ ├── info/ │ │ │ │ │ ├── association/ │ │ │ │ │ │ ├── FileTypeSyntaxAssociationService.java │ │ │ │ │ │ └── FileTypeSyntaxAssociationServiceConfig.java │ │ │ │ │ └── summary/ │ │ │ │ │ ├── ResourceSummarizer.java │ │ │ │ │ ├── ResourceSummaryService.java │ │ │ │ │ ├── ResourceSummaryServiceConfig.java │ │ │ │ │ ├── SummaryConsumer.java │ │ │ │ │ └── builtin/ │ │ │ │ │ ├── AntiDecompilationSummarizer.java │ │ │ │ │ ├── EntryPointSummarizer.java │ │ │ │ │ ├── HashSummarizer.java │ │ │ │ │ └── JarSigningSummarizer.java │ │ │ │ ├── mapping/ │ │ │ │ │ └── MappingHelper.java │ │ │ │ ├── navigation/ │ │ │ │ │ ├── Actions.java │ │ │ │ │ ├── ActionsConfig.java │ │ │ │ │ ├── ClassNavigable.java │ │ │ │ │ ├── FileNavigable.java │ │ │ │ │ ├── Navigable.java │ │ │ │ │ ├── NavigableAddListener.java │ │ │ │ │ ├── NavigableRemoveListener.java │ │ │ │ │ ├── NavigationManager.java │ │ │ │ │ ├── NavigationManagerConfig.java │ │ │ │ │ ├── UnsupportedContentException.java │ │ │ │ │ └── UpdatableNavigable.java │ │ │ │ ├── translation/ │ │ │ │ │ └── LangConfig.java │ │ │ │ ├── tutorial/ │ │ │ │ │ ├── TutorialWorkspaceBuilder.java │ │ │ │ │ └── content/ │ │ │ │ │ ├── Chapter1.java │ │ │ │ │ ├── Chapter2.java │ │ │ │ │ ├── Chapter3.java │ │ │ │ │ ├── Chapter4.java │ │ │ │ │ ├── Chapter5.java │ │ │ │ │ ├── Chapter6.java │ │ │ │ │ └── Chapter7.java │ │ │ │ └── window/ │ │ │ │ ├── WindowFactory.java │ │ │ │ ├── WindowFactoryConfig.java │ │ │ │ ├── WindowManager.java │ │ │ │ ├── WindowManagerConfig.java │ │ │ │ ├── WindowStyling.java │ │ │ │ └── WindowStylingConfig.java │ │ │ ├── ui/ │ │ │ │ ├── LanguageStylesheets.java │ │ │ │ ├── RecafTheme.java │ │ │ │ ├── config/ │ │ │ │ │ ├── Binding.java │ │ │ │ │ ├── BindingCreator.java │ │ │ │ │ ├── ClassEditingConfig.java │ │ │ │ │ ├── ExportConfig.java │ │ │ │ │ ├── KeybindingConfig.java │ │ │ │ │ ├── MemberDisplayFormatConfig.java │ │ │ │ │ ├── RecentFilesConfig.java │ │ │ │ │ ├── WindowScaleConfig.java │ │ │ │ │ └── WorkspaceExplorerConfig.java │ │ │ │ ├── contextmenu/ │ │ │ │ │ ├── AnnotationMenuBuilder.java │ │ │ │ │ ├── BundleMenuBuilder.java │ │ │ │ │ ├── ContextMenuBuilder.java │ │ │ │ │ ├── DirectoryMenuBuilder.java │ │ │ │ │ ├── InfoMenuBuilder.java │ │ │ │ │ ├── ItemSink.java │ │ │ │ │ ├── MemberMenuBuilder.java │ │ │ │ │ ├── MenuBuilder.java │ │ │ │ │ ├── MenuHandler.java │ │ │ │ │ ├── ResourceMenuBuilder.java │ │ │ │ │ ├── WorkspaceMenuBuilder.java │ │ │ │ │ └── actions/ │ │ │ │ │ ├── AnnotationAction.java │ │ │ │ │ ├── BundleAction.java │ │ │ │ │ ├── DirectoryAction.java │ │ │ │ │ ├── InfoAction.java │ │ │ │ │ ├── MemberAction.java │ │ │ │ │ ├── ResourceAction.java │ │ │ │ │ └── WorkspaceAction.java │ │ │ │ ├── control/ │ │ │ │ │ ├── AbstractSearchBar.java │ │ │ │ │ ├── ActionButton.java │ │ │ │ │ ├── ActionMenu.java │ │ │ │ │ ├── ActionMenuItem.java │ │ │ │ │ ├── AutoScrollPane.java │ │ │ │ │ ├── BoundBiDiComboBox.java │ │ │ │ │ ├── BoundCheckBox.java │ │ │ │ │ ├── BoundComboBox.java │ │ │ │ │ ├── BoundHyperlink.java │ │ │ │ │ ├── BoundIntSpinner.java │ │ │ │ │ ├── BoundLabel.java │ │ │ │ │ ├── BoundMultiToggleIcon.java │ │ │ │ │ ├── BoundTab.java │ │ │ │ │ ├── BoundTextField.java │ │ │ │ │ ├── BoundToggleIcon.java │ │ │ │ │ ├── ClosableActionMenuItem.java │ │ │ │ │ ├── DynamicNumericTextField.java │ │ │ │ │ ├── FontIconView.java │ │ │ │ │ ├── GraphicActionButton.java │ │ │ │ │ ├── IconView.java │ │ │ │ │ ├── ImageCanvas.java │ │ │ │ │ ├── ModalPaneComponent.java │ │ │ │ │ ├── ObservableCheckBox.java │ │ │ │ │ ├── ObservableComboBox.java │ │ │ │ │ ├── ObservableSpinner.java │ │ │ │ │ ├── PannableView.java │ │ │ │ │ ├── PathNodeTree.java │ │ │ │ │ ├── ReorderableListCell.java │ │ │ │ │ ├── ResizableCanvas.java │ │ │ │ │ ├── SubLabeled.java │ │ │ │ │ ├── Tooltipable.java │ │ │ │ │ ├── VirtualizedScrollPaneWrapper.java │ │ │ │ │ ├── graph/ │ │ │ │ │ │ ├── MethodCallGraphPane.java │ │ │ │ │ │ └── MethodCallGraphsPane.java │ │ │ │ │ ├── popup/ │ │ │ │ │ │ ├── AddMemberPopup.java │ │ │ │ │ │ ├── ChangeClassVersionPopup.java │ │ │ │ │ │ ├── ClassSelectionPopup.java │ │ │ │ │ │ ├── DecompileAllPopup.java │ │ │ │ │ │ ├── ItemListSelectionPopup.java │ │ │ │ │ │ ├── ItemTreeSelectionPopup.java │ │ │ │ │ │ ├── NamePopup.java │ │ │ │ │ │ ├── OpenUrlPopup.java │ │ │ │ │ │ ├── OverrideMethodPopup.java │ │ │ │ │ │ └── SelectionPopup.java │ │ │ │ │ ├── richtext/ │ │ │ │ │ │ ├── AbstractLineItemTracking.java │ │ │ │ │ │ ├── Editor.java │ │ │ │ │ │ ├── EditorComponent.java │ │ │ │ │ │ ├── SafeCodeArea.java │ │ │ │ │ │ ├── ScrollbarPaddingUtil.java │ │ │ │ │ │ ├── bracket/ │ │ │ │ │ │ │ ├── BracketMatchGraphicFactory.java │ │ │ │ │ │ │ └── SelectedBracketTracking.java │ │ │ │ │ │ ├── inheritance/ │ │ │ │ │ │ │ ├── Inheritance.java │ │ │ │ │ │ │ ├── InheritanceGutterGraphicFactory.java │ │ │ │ │ │ │ ├── InheritanceInvalidationListener.java │ │ │ │ │ │ │ └── InheritanceTracking.java │ │ │ │ │ │ ├── linegraphics/ │ │ │ │ │ │ │ ├── AbstractLineGraphicFactory.java │ │ │ │ │ │ │ ├── AbstractTextBoundLineGraphicFactory.java │ │ │ │ │ │ │ ├── LineContainer.java │ │ │ │ │ │ │ ├── LineGraphicFactory.java │ │ │ │ │ │ │ ├── LineNumberFactory.java │ │ │ │ │ │ │ └── RootLineGraphicFactory.java │ │ │ │ │ │ ├── problem/ │ │ │ │ │ │ │ ├── Problem.java │ │ │ │ │ │ │ ├── ProblemGutterGraphicFactory.java │ │ │ │ │ │ │ ├── ProblemInvalidationListener.java │ │ │ │ │ │ │ ├── ProblemLevel.java │ │ │ │ │ │ │ ├── ProblemPhase.java │ │ │ │ │ │ │ ├── ProblemSquiggleGraphicFactory.java │ │ │ │ │ │ │ └── ProblemTracking.java │ │ │ │ │ │ ├── search/ │ │ │ │ │ │ │ └── SearchBar.java │ │ │ │ │ │ ├── source/ │ │ │ │ │ │ │ ├── JavaContextActionManager.java │ │ │ │ │ │ │ └── JavaContextActionSupport.java │ │ │ │ │ │ ├── suggest/ │ │ │ │ │ │ │ ├── AssemblerTabCompleter.java │ │ │ │ │ │ │ ├── CompletionPopup.java │ │ │ │ │ │ │ ├── CompletionPopupFocuser.java │ │ │ │ │ │ │ ├── CompletionPopupUpdater.java │ │ │ │ │ │ │ ├── CompletionValueGraphicMapper.java │ │ │ │ │ │ │ ├── CompletionValueTextifier.java │ │ │ │ │ │ │ ├── ExistingWordTabCompleter.java │ │ │ │ │ │ │ ├── TabCompleter.java │ │ │ │ │ │ │ └── TabCompletionConfig.java │ │ │ │ │ │ └── syntax/ │ │ │ │ │ │ ├── AbstractSyntaxHighlighter.java │ │ │ │ │ │ ├── RegexLanguages.java │ │ │ │ │ │ ├── RegexRule.java │ │ │ │ │ │ ├── RegexSyntaxHighlighter.java │ │ │ │ │ │ ├── StyleResult.java │ │ │ │ │ │ ├── SyntaxHighlighter.java │ │ │ │ │ │ └── SyntaxUtil.java │ │ │ │ │ └── tree/ │ │ │ │ │ ├── FilterableTreeItem.java │ │ │ │ │ ├── TreeFiltering.java │ │ │ │ │ ├── TreeItems.java │ │ │ │ │ ├── WorkspaceRootTreeNode.java │ │ │ │ │ ├── WorkspaceTree.java │ │ │ │ │ ├── WorkspaceTreeCell.java │ │ │ │ │ ├── WorkspaceTreeFilterPane.java │ │ │ │ │ └── WorkspaceTreeNode.java │ │ │ │ ├── dnd/ │ │ │ │ │ ├── DragAndDrop.java │ │ │ │ │ ├── FileDropListener.java │ │ │ │ │ └── WorkspaceLoadingDropListener.java │ │ │ │ ├── docking/ │ │ │ │ │ ├── DockingManager.java │ │ │ │ │ └── EmbeddedBento.java │ │ │ │ ├── media/ │ │ │ │ │ ├── CombinedPlayer.java │ │ │ │ │ ├── FxPlayer.java │ │ │ │ │ ├── MediaHacker.java │ │ │ │ │ ├── Player.java │ │ │ │ │ ├── SpectrumEvent.java │ │ │ │ │ └── SpectrumListener.java │ │ │ │ ├── menubar/ │ │ │ │ │ ├── AnalysisMenu.java │ │ │ │ │ ├── ConfigMenu.java │ │ │ │ │ ├── FileMenu.java │ │ │ │ │ ├── HelpMenu.java │ │ │ │ │ ├── MainMenu.java │ │ │ │ │ ├── MainMenuProvider.java │ │ │ │ │ ├── MappingMenu.java │ │ │ │ │ ├── ScriptMenu.java │ │ │ │ │ ├── SearchMenu.java │ │ │ │ │ └── WorkspaceAwareMenu.java │ │ │ │ ├── pane/ │ │ │ │ │ ├── CommentEditPane.java │ │ │ │ │ ├── CommentListPane.java │ │ │ │ │ ├── ConfigPane.java │ │ │ │ │ ├── DocumentationPane.java │ │ │ │ │ ├── LoggingPane.java │ │ │ │ │ ├── MappingApplicationPane.java │ │ │ │ │ ├── MappingGeneratorPane.java │ │ │ │ │ ├── MappingProgressPane.java │ │ │ │ │ ├── RemoteVirtualMachinesPane.java │ │ │ │ │ ├── ScriptManagerPane.java │ │ │ │ │ ├── SystemInformationPane.java │ │ │ │ │ ├── WelcomePane.java │ │ │ │ │ ├── WorkspaceBuilderPane.java │ │ │ │ │ ├── WorkspaceExplorerPane.java │ │ │ │ │ ├── WorkspaceInformationPane.java │ │ │ │ │ ├── editing/ │ │ │ │ │ │ ├── AbstractClassInfoProvider.java │ │ │ │ │ │ ├── AbstractContentPane.java │ │ │ │ │ │ ├── AbstractDecompilePane.java │ │ │ │ │ │ ├── AbstractDecompilerPaneConfigurator.java │ │ │ │ │ │ ├── ClassPane.java │ │ │ │ │ │ ├── DecompileFailureButton.java │ │ │ │ │ │ ├── FileDisplayMode.java │ │ │ │ │ │ ├── FilePane.java │ │ │ │ │ │ ├── ProblemOverlay.java │ │ │ │ │ │ ├── SideTabsInjector.java │ │ │ │ │ │ ├── ToolsContainerComponent.java │ │ │ │ │ │ ├── android/ │ │ │ │ │ │ │ ├── AndroidClassEditorType.java │ │ │ │ │ │ │ ├── AndroidClassInfoProvider.java │ │ │ │ │ │ │ ├── AndroidClassPane.java │ │ │ │ │ │ │ ├── AndroidDecompilerPane.java │ │ │ │ │ │ │ └── AndroidDecompilerPaneConfigurator.java │ │ │ │ │ │ ├── assembler/ │ │ │ │ │ │ │ ├── AssemblerAstConsumer.java │ │ │ │ │ │ │ ├── AssemblerBuildConsumer.java │ │ │ │ │ │ │ ├── AssemblerContextActionSupport.java │ │ │ │ │ │ │ ├── AssemblerPane.java │ │ │ │ │ │ │ ├── AssemblerToolTabs.java │ │ │ │ │ │ │ ├── AstBuildConsumerComponent.java │ │ │ │ │ │ │ ├── AstPhase.java │ │ │ │ │ │ │ ├── AstUsages.java │ │ │ │ │ │ │ ├── ContextualAssemblerComponent.java │ │ │ │ │ │ │ ├── ControlFlowLines.java │ │ │ │ │ │ │ ├── ControlFlowLinesConfig.java │ │ │ │ │ │ │ ├── JvmAssemblerBuildConsumer.java │ │ │ │ │ │ │ ├── JvmExpressionCompilerPane.java │ │ │ │ │ │ │ ├── JvmStackAnalysisPane.java │ │ │ │ │ │ │ ├── JvmVariablesPane.java │ │ │ │ │ │ │ ├── LabelData.java │ │ │ │ │ │ │ ├── SnippetsPane.java │ │ │ │ │ │ │ ├── TypeTableCell.java │ │ │ │ │ │ │ ├── ValueTableCell.java │ │ │ │ │ │ │ ├── VariableData.java │ │ │ │ │ │ │ └── resolve/ │ │ │ │ │ │ │ ├── AssemblyResolution.java │ │ │ │ │ │ │ ├── AssemblyResolver.java │ │ │ │ │ │ │ ├── ClassAnnotationResolution.java │ │ │ │ │ │ │ ├── ClassExtends.java │ │ │ │ │ │ │ ├── ClassImplements.java │ │ │ │ │ │ │ ├── EmptyResolution.java │ │ │ │ │ │ │ ├── FieldAnnotationResolution.java │ │ │ │ │ │ │ ├── FieldResolution.java │ │ │ │ │ │ │ ├── IndependentAnnotationResolution.java │ │ │ │ │ │ │ ├── InnerClassResolution.java │ │ │ │ │ │ │ ├── InstructionResolution.java │ │ │ │ │ │ │ ├── LabelDeclarationResolution.java │ │ │ │ │ │ │ ├── LabelReferenceResolution.java │ │ │ │ │ │ │ ├── MethodAnnotationResolution.java │ │ │ │ │ │ │ ├── MethodResolution.java │ │ │ │ │ │ │ ├── TypeReferenceResolution.java │ │ │ │ │ │ │ └── VariableDeclarationResolution.java │ │ │ │ │ │ ├── binary/ │ │ │ │ │ │ │ ├── DecodingXmlPane.java │ │ │ │ │ │ │ ├── ElfPane.java │ │ │ │ │ │ │ ├── PePane.java │ │ │ │ │ │ │ └── hex/ │ │ │ │ │ │ │ ├── HexAdapter.java │ │ │ │ │ │ │ ├── HexConfig.java │ │ │ │ │ │ │ ├── HexEditor.java │ │ │ │ │ │ │ ├── HexUtil.java │ │ │ │ │ │ │ ├── cell/ │ │ │ │ │ │ │ │ ├── EditableAsciiCell.java │ │ │ │ │ │ │ │ ├── EditableHexCell.java │ │ │ │ │ │ │ │ ├── HexCell.java │ │ │ │ │ │ │ │ ├── HexCellBase.java │ │ │ │ │ │ │ │ ├── HexRow.java │ │ │ │ │ │ │ │ └── HexRowHeader.java │ │ │ │ │ │ │ └── ops/ │ │ │ │ │ │ │ ├── HexAccess.java │ │ │ │ │ │ │ ├── HexNavigation.java │ │ │ │ │ │ │ └── HexOperations.java │ │ │ │ │ │ ├── jvm/ │ │ │ │ │ │ │ ├── DecompilerPaneConfig.java │ │ │ │ │ │ │ ├── JvmClassEditorType.java │ │ │ │ │ │ │ ├── JvmClassInfoProvider.java │ │ │ │ │ │ │ ├── JvmClassPane.java │ │ │ │ │ │ │ ├── JvmDecompilerPane.java │ │ │ │ │ │ │ ├── JvmDecompilerPaneConfigurator.java │ │ │ │ │ │ │ └── lowlevel/ │ │ │ │ │ │ │ ├── ClassElement.java │ │ │ │ │ │ │ ├── ClassItem.java │ │ │ │ │ │ │ ├── JvmLowLevelPane.java │ │ │ │ │ │ │ └── LazyClassElement.java │ │ │ │ │ │ ├── media/ │ │ │ │ │ │ │ ├── AudioPane.java │ │ │ │ │ │ │ ├── ImagePane.java │ │ │ │ │ │ │ ├── MediaPane.java │ │ │ │ │ │ │ └── VideoPane.java │ │ │ │ │ │ ├── tabs/ │ │ │ │ │ │ │ ├── FieldsAndMethodsPane.java │ │ │ │ │ │ │ ├── InheritancePane.java │ │ │ │ │ │ │ └── KotlinMetadataPane.java │ │ │ │ │ │ └── text/ │ │ │ │ │ │ └── TextPane.java │ │ │ │ │ └── search/ │ │ │ │ │ ├── AbstractMemberSearchPane.java │ │ │ │ │ ├── AbstractSearchPane.java │ │ │ │ │ ├── ClassReferenceSearchPane.java │ │ │ │ │ ├── InstructionSearchPane.java │ │ │ │ │ ├── MemberDeclarationSearchPane.java │ │ │ │ │ ├── MemberReferenceSearchPane.java │ │ │ │ │ ├── NumberSearchPane.java │ │ │ │ │ ├── SearchContextSource.java │ │ │ │ │ └── StringSearchPane.java │ │ │ │ ├── window/ │ │ │ │ │ ├── AbstractIdentifiableStage.java │ │ │ │ │ ├── ConfigWindow.java │ │ │ │ │ ├── DeobfuscationWindow.java │ │ │ │ │ ├── IdentifiableStage.java │ │ │ │ │ ├── MappingApplicationWindow.java │ │ │ │ │ ├── MappingGeneratorWindow.java │ │ │ │ │ ├── MappingProgressWindow.java │ │ │ │ │ ├── QuickNavWindow.java │ │ │ │ │ ├── RecafScene.java │ │ │ │ │ ├── RecafStage.java │ │ │ │ │ ├── RemoteVirtualMachinesWindow.java │ │ │ │ │ ├── ScriptManagerWindow.java │ │ │ │ │ └── SystemInformationWindow.java │ │ │ │ └── wizard/ │ │ │ │ ├── Wizard.java │ │ │ │ └── WizardStage.java │ │ │ ├── util/ │ │ │ │ ├── Animations.java │ │ │ │ ├── ClipboardUtil.java │ │ │ │ ├── Colors.java │ │ │ │ ├── DirectoryChooserBuilder.java │ │ │ │ ├── Effects.java │ │ │ │ ├── ErrorDialogs.java │ │ │ │ ├── FileChooserBuilder.java │ │ │ │ ├── FileChooserBundle.java │ │ │ │ ├── FxThreadUtil.java │ │ │ │ ├── Icons.java │ │ │ │ ├── IntRange.java │ │ │ │ ├── JFXValidation.java │ │ │ │ ├── Lang.java │ │ │ │ ├── Menus.java │ │ │ │ ├── NodeEvents.java │ │ │ │ ├── RecafURLStreamHandlerProvider.java │ │ │ │ ├── SVG.java │ │ │ │ ├── SceneUtils.java │ │ │ │ ├── SynchronizedSimpleStringProperty.java │ │ │ │ ├── SynchronizedStringBinding.java │ │ │ │ ├── TextDisplayUtil.java │ │ │ │ ├── ToStringConverter.java │ │ │ │ └── Translatable.java │ │ │ └── workspace/ │ │ │ ├── PathExportingManager.java │ │ │ ├── PathLoadingManager.java │ │ │ └── WorkspacePreLoadListener.java │ │ └── resources/ │ │ ├── icons/ │ │ │ └── Jetbrains-LICENSE.txt │ │ ├── style/ │ │ │ ├── code-editor.css │ │ │ ├── docking.css │ │ │ ├── hex.css │ │ │ ├── recaf.css │ │ │ └── tweaks.css │ │ ├── syntax/ │ │ │ ├── enigma.css │ │ │ ├── enigma.json │ │ │ ├── jasm.css │ │ │ ├── jasm.json │ │ │ ├── java.css │ │ │ ├── java.json │ │ │ ├── json.css │ │ │ ├── json.json │ │ │ ├── xml.css │ │ │ └── xml.json │ │ └── translations/ │ │ ├── cs_CZ.lang │ │ ├── de_DE.lang │ │ ├── en_US.lang │ │ ├── ja_JP.lang │ │ ├── pl_PL.lang │ │ ├── ru_RU.lang │ │ ├── sv_SE.lang │ │ └── zh_CN.lang │ └── test/ │ └── java/ │ └── software/ │ └── coley/ │ └── recaf/ │ ├── services/ │ │ └── script/ │ │ └── ScriptManagerTest.java │ └── ui/ │ ├── BaseFxTest.java │ └── control/ │ ├── richtext/ │ │ ├── bracket/ │ │ │ └── SelectedBracketTrackingTest.java │ │ ├── problem/ │ │ │ └── ProblemTrackingTest.java │ │ └── syntax/ │ │ └── RegexSyntaxHighlighterTest.java │ └── tree/ │ └── WorkspaceTreeNodeTest.java ├── settings.gradle └── setup/ ├── code-style-eclipsej.xml └── code-style-intellij.xml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ # Auto detect text files and perform LF normalization * text=auto # Java sources *.java text diff=java eol=lf *.gradle text diff=java eol=lf # These files are text and should be normalized (Convert crlf => lf) *.css text diff=css eol=lf *.html text diff=html eol=lf *.md text diff=markdown eol=lf *.js text eol=lf *.csv text eol=lf *.json text eol=lf *.properties text eol=lf *.svg text eol=lf *.xml text eol=lf *.yaml text eol=lf *.yml text eol=lf *.toml text eol=lf *.lang text eol=lf # These files are binary and should be left untouched *.png binary *.gif binary *.jpg binary *.jpeg binary # Common build-tool wrapper scripts mvnw text eol=lf gradlew text eol=lf *.sh text eol=lf *.bat text eol=crlf *.cmd text eol=crlf ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to notify developers of unintended behavior --- **Describe the bug** > A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Open sample '...' _(provide sample download if possible)_ 2. Navigate to '....' 3. Do something '....' 4. See error **Exception** If applicable, add the exception/stacktrace. ``` stacktrace goes here ``` **Screenshots** If applicable, add screenshots to help explain your problem. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest a feature to improve Recaf --- ## Feature_Name Description of the feature ================================================ FILE: .github/ISSUE_TEMPLATE/other.md ================================================ --- name: Other about: If it isn't a bug or a feature request, choose this --- ================================================ FILE: .github/workflows/build.yml ================================================ name: CI/CD on: push: branches: - master pull_request: branches: - master workflow_dispatch: inputs: is-a-release: description: Publish release? (Only works on master, and for untagged versions) type: boolean permissions: contents: write jobs: test: name: Run test suite strategy: fail-fast: false matrix: os: [ ubuntu-latest ] java-version: [ 22 ] runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v3 - name: Setup JDK uses: actions/setup-java@v3 with: distribution: temurin java-version: 22 check-latest: true # The project version extract NEEDS to have the gradle wrapper already downloaded. # So we have a dummy step here just to initialize it. - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 # Run the tests and upload results/coverage - name: Run tests run: ./gradlew test - name: Upload test coverage to CodeCov.io uses: codecov/codecov-action@v3 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - name: Upload test artifacts if: always() uses: actions/upload-artifact@v4 with: name: test-artifacts retention-days: 21 path: | **/TEST-* **/hs_err_pid* # Build the distribution jar and upload it, without bundling JavaFX in the jar - name: Create distribution jar run: ./gradlew assemble -x compileTestJava -Dskip_jfx_bundle=true - name: Upload distribution jar if: always() uses: actions/upload-artifact@v4 with: name: snapshot-build retention-days: 30 path: | recaf-ui/build/libs/recaf-ui-*-all.jar # Publishes the test results from prior task work publish-test-results: name: Publish tests results needs: test if: always() runs-on: ubuntu-latest permissions: checks: write pull-requests: write steps: - name: Download artifacts uses: actions/download-artifact@v4 with: name: test-artifacts - name: Publish test results uses: EnricoMi/publish-unit-test-result-action@v2 with: check_name: Unit test results files: | **/*.xml # Builds the projects and attempts to publish a release if the current project version # does not match any existing tags in the repository. build-and-release: name: Publish release needs: test if: inputs.is-a-release && github.repository == 'Col-E/Recaf' && github.ref == 'refs/heads/master' strategy: fail-fast: false runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v3 with: fetch-depth: 0 # Required depth for JReleaser - name: Setup Java JDK uses: actions/setup-java@v3 with: distribution: temurin java-version: 22 # The project version extract NEEDS to have the gradle wrapper already downloaded. # So we have a dummy step here just to initialize it. - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 # Set environment variable for the project version: "var_to_set=$(command_to_run)" >> sink # - For maven: echo "PROJECT_VERSION=$(./mvnw help:evaluate -Dexpression=project.version -q -DforceStdout)" >> $GITHUB_ENV # - For gradle: echo "PROJECT_VERSION=$(./gradlew properties | grep -Po '(?<=version: ).*')" >> $GITHUB_ENV - name: Extract project version to environment variable run: echo "PROJECT_VERSION=$(./gradlew properties | grep -Po '(?<=version\W ).*')" >> $GITHUB_ENV # Check if a tag exists that matches the current project version. # Write the existence state to the step output 'tagExists'. - name: Check the package version has corresponding Git tag id: tagged shell: bash run: | git show-ref --tags --verify --quiet -- "refs/tags/${{ env.PROJECT_VERSION }}" && echo "tagExists=1" >> $GITHUB_OUTPUT || echo "tagExists=0" >> $GITHUB_OUTPUT git show-ref --tags --verify --quiet -- "refs/tags/${{ env.PROJECT_VERSION }}" && echo "Tag for current version exists" || echo "Tag for current version does not exist" # If the tag could not be fetched, show a message and abort the job. # The wonky if logic is a workaround for: https://github.com/actions/runner/issues/1173 - name: Abort if tag exists, or existence check fails if: ${{ false && steps.tagged.outputs.tagExists }} run: | echo "Output of 'tagged' step: ${{ steps.tagged.outputs.tagExists }}" echo "Failed to check if tag exists." echo "PROJECT_VERSION: ${{ env.PROJECT_VERSION }}" echo "Tags $(git tag | wc -l):" git tag git show-ref --tags --verify -- "refs/tags/${{ env.PROJECT_VERSION }}" exit 1 # Run build to generate the release artifacts. # Tag does not exist AND trigger was manual. Deploy release artifacts! - name: Build release artifacts run: ./gradlew publish # TODO: Publish all modules' artifacts into a single directory # Make release with JReleaser, only running when the project version does not exist as a tag on the repository. - name: Publish release uses: jreleaser/release-action@v2 with: arguments: full-release env: JRELEASER_PROJECT_VERSION: ${{ env.PROJECT_VERSION }} JRELEASER_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} JRELEASER_GPG_PASSPHRASE: ${{ secrets.JRELEASER_GPG_PASSPHRASE }} JRELEASER_GPG_PUBLIC_KEY: ${{ secrets.JRELEASER_GPG_PUBLIC_KEY }} JRELEASER_GPG_SECRET_KEY: ${{ secrets.JRELEASER_GPG_SECRET_KEY }} JRELEASER_MAVENCENTRAL_USERNAME: ${{ secrets.JRELEASER_MAVENCENTRAL_USERNAME }} JRELEASER_MAVENCENTRAL_TOKEN: ${{ secrets.JRELEASER_MAVENCENTRAL_TOKEN }} # Upload JRelease debug log - name: JReleaser output uses: actions/upload-artifact@v4 if: always() with: name: jreleaser-release path: | out/jreleaser/trace.log out/jreleaser/output.properties ================================================ FILE: .gitignore ================================================ # IntelliJ out/ .idea/ .idea_modules/ *.iws *.iml # Eclipse .settings/ .classpath .checkstyle .project # Gradle target/ build/ generated/ gradle-app.setting .gradle .gradletasknamecache !gradle-wrapper.jar **/build/ # Misc hs_err_pid* *.log *.ctxt temp/ # IntelliJ lib/ ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Recaf The following is a series of guidelines for contributing to Recaf. They're not _"rules"_ per say, rather they're more like goals to strive towards. Regardless of how closely you adhere to the following guidelines I really appreciate you taking the time to contribute, it means a lot :+1: **Table of Contents** - [What if I am not a programmer?](#what-if-i-am-not-a-programmer) - [What should I know before I get started?](#what-should-i-know-before-getting-started) - [Is there a todo list?](#is-there-a-to-do-list) - [Reporting Bugs](#reporting-bugs) - [Suggesting Features](#suggesting-features) - [Coding Guidelines](#coding-guidelines) - [Pull Requests](#pull-requests) **TLDR?** - Follow the code style. - Document and comment your code. - Make sure the tests pass after making changes. - Translations and feature ideas are appreciated too. **Questions?** You can DM `invokecoley` on discord, or join the [Recaf discord](https://discord.gg/Bya5HaA). ## What if I am not a programmer? [There is plenty to contribute that isn't based in code.](https://www.youtube.com/watch?v=GAqfMNB-YBU&t=603) For example, you can contribute ideas, add translations, or write documentation: - [Documentation source](https://github.com/Col-E/recaf-site) - [Documentation site](https://recaf.coley.software/) ## What should I know before getting started? It depends on what changes you are making. For instance, changing the user-interface requires very minimal or no reverse-engineering prior knowledge. If you do need JVM reversal knowledge to work on a feature you can check the [primer guide](PRIMER.md) which points to several good resources and outlines key details. For a general understanding of how Recaf works and how to begin creating contributions you can read our [getting started](https://recaf.coley.software/dev/getting-started.html) page as well. ## Is there a to-do list? Unfortunately the to-do list is scattered around a few places. We're working on eventually consolidating everything into one place. ## Reporting Bugs When creating an issue select the `Bug report` button. This will provide a template that you can fill in the details for your bug. Please include as much information as possible. This can include: - Clear and descriptive title - Log files - Steps to reproduce the bug - An explanation of what you _\*expected\*_ to happen - The file being analyzed _(Do not share anything you do not own the rights to)_ ## Suggesting Features When creating an issue select the `Feature request` button. This will provide a template that you can fill in the details for your feature idea. Be as descriptive as possible with your idea. **Note**: Not all ideas may be within Recaf's scope. In these cases the feature should be implemented as a script or plugin. ## Coding Guidelines **Style**: IDE code formatting rules can be found in the [`/setup` directory](setup/). **Commits**: Try and keep commits small and focused on one thing at a time. ## Pull Requests When creating a pull request please consider the following when filling in the template: - Clear and descriptive title - A clear description of what changes are included in the pull Github's PR system will validate that your changes compile and pass the unit tests as well. ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2017-2025 Matthew Coley Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: PRIMER.md ================================================ # What should I know before getting started? ## JVM / Class file format ### General concepts A basic understanding of the JVM / class file format is _highly_ reccomended before contributing. Here are some articles that should bring you up to speed: - [JVM Architecture 101: Get to Know Your Virtual Machine](https://blog.overops.com/jvm-architecture-101-get-to-know-your-virtual-machine/) - [JVM Internals](https://blog.jamesdbloom.com/JVMInternals.html) - [Java Code To Byte Code](https://blog.jamesdbloom.com/JavaCodeToByteCode_PartOne.html) ### Terminology **Qualified name**: Package separators using the `.` character. These are names used by runtime functions like `Class.forName(name)`. For example: - `java.lang.String` - `com.example.MyClass.InnerClass` **Internal name**: Package separators using the `/` character. Inner classes specified with the `$` character. These are names how classes are specified internally in the class file. For example: - `java/lang/String` - `com/example/MyClass$InnerClass` Primitives *(Not the boxed types)* use single characters: | Primitive | Internal | |-----------|----------| | `long` | `J` | | `int` | `I` | | `short` | `S` | | `byte` | `B` | | `boolean` | `Z` | | `float` | `F` | | `double` | `D` | | `void` | `V` | **Descriptor**: Used to describe field and method types. These are essentially the same as internal names, but class names are wrapped in a prefix (`L`) and suffix character (`;`). For example: * `Ljava/lang/String;` * `I` _(primitives stay the same)_ Method descriptors are formatted like so: * `double method(int i, String s)` = `(ILjava/lang/String;)D` * `void method()` = `()V` Arrays are prefixed with a `[` for each level of the array. * `int[]` = `[I` * `String[][]` = `[[Ljava/lang/String;` ### Quirks **Wide types**: `double` and `long` typed variables take up two slots _(On the stack and in the local variable table)_. For example, declaring two doubles in a static method will use slots 0, then 2. Slots 0-3 are all in-use. **Lambdas**: The content of a lambda is defined in compiler-generated hidden methods and are invoked with `INVOKEDYNAMIC`. Decompilers will in-line the code so that it looks more similar to the source representation. ================================================ FILE: PULL_REQUEST_TEMPLATE.md ================================================ ## What's new * Summary of additions ## What's fixed * Summary of bugs fixed ================================================ FILE: README.md ================================================ # Recaf [![Discord](https://dcbadge.limes.pink/api/server/https://discord.gg/Bya5HaA?style=flat)](https://discord.gg/Bya5HaA) [![codecov](https://codecov.io/gh/Col-E/Recaf/graph/badge.svg?token=N8GslpI1lL)](https://codecov.io/gh/Col-E/Recaf) ![downloads](https://img.shields.io/github/downloads/Col-E/Recaf/total.svg) [![Contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](CONTRIBUTING.md) ![Recaf 4x UI](recaf.png) An easy to use modern Java bytecode editor that abstracts away the complexities of Java programs. ## Download - [Launcher](https://github.com/Col-E/Recaf-Launcher) - Usage & instructions found on the launcher repo - [Snapshot releases](https://github.com/Col-E/Recaf-Launcher/blob/master/MANUAL.md) - See [CI actions](https://github.com/Col-E/Recaf/actions/workflows/build.yml) for release artifacts - [Independent releases](https://github.com/Col-E/Recaf/releases) _(None for 4X currently)_ ## Features - Edit Java bytecode with ease from a high or low level _(minus the annoying parts)_ - Editor features within Recaf abstract away complex details of compiled Java applications like: - The constant pool - Stack frame calculation - Using wide instructions when needed - And more! - Easy to use navigable interface with context-sensitive actions - Support for standard Java _and_ Android applications - Multiple decompilers to switch between, with all of their parameters made fully configurable - Built in compiler to allow recompiling decompiled classes, even if some referenced classes are missing *(When supported, support may vary depending on code complexity and obfuscation)* - A bytecode assembler with a simple syntax, and supporting tooling - See the state of local variables and stack values at any point in methods - Access variables by names instead of indices for clearer disassembled code - Convert snippets of Java source code to bytecode sequences automatically - Searching for a variety of different content: Strings/numeric constants, classes and member references, instruction patterns - Tools for deobfuscating obfuscated code - Specially crafted class files with the intent of crashing reverse engineering tools are automatically patched when opened in Recaf - Specially crafted jar/zip files are read as the JVM does, bypassing sneaky tricks that can trick reverse engineering tools into showing the wrong data - Support for automatically renaming obfuscated classes and their members - Support for manually renaming classes and their members *_(And exporting these mappings to a variety of mapping formats for use in other tools)_* - Bytecode transformers for simplifying common obfuscation strategies - Attach to running Java process with instrumentation capabilities - And much more A complete list of features can be found in the [user documentation](https://recaf.coley.software/user/index.html). ## Scripting & Plugins Recaf exposes almost all of its functionality through modular API's. Automating behaviors can be done easily with scripts, or with plugins for more complex situations. Additional features can also be added via plugins, which can register hooks in API's that offer them. To create your own script or plugin, see the [developer documentation](https://recaf.coley.software/dev/index.html), specifically the _"plugins & scripts"_ section. ## Command Line Recaf can run as a command line application, which can be especially useful when paired with scripts provided at startup. You can see all the current launch arguments by passing `--help` as an application argument. ## Development Setup Clone the repository via `git clone https://github.com/Col-E/Recaf.git` Open the project in an IDE or generate the build with gradle. **IDE**: 1. Import the project from the `build.gradle` file 2. Create a run configuration with the main class `software.coley.recaf.Main` **Without IDE**: 1. Run `gradlew build` - Output will be located at: `recaf-ui/build/libs/recaf-ui-{VERSION}-all.jar` ================================================ FILE: build.gradle ================================================ plugins { alias(libs.plugins.benmanes.versions) apply false alias(libs.plugins.coverage.report.aggregator) alias(libs.plugins.checker.processor) apply false } allprojects { group = 'software.coley' version = '4.0.0-SNAPSHOT' } subprojects { apply plugin: 'java' apply plugin: 'jacoco' apply plugin: 'maven-publish' apply plugin: 'com.github.ben-manes.versions' repositories { mavenLocal() mavenCentral() google() maven { url = 'https://jitpack.io' name = 'JitPack' } } // ======================= DEPENDENCIES ======================== dependencies { // Enforce jakarta annotations everywhere as the standard for Nullable/Nonnull implementation(libs.jakarta.annotation) // Local libraries for internal use only // (none of the types from these libraries should be part of a public API) implementation fileTree(dir: "$rootProject.projectDir/libs", include: ['*.jar']) } configurations.configureEach { // Annoying annotations that replace desired tab completions. exclude group: 'org.checkerframework' // Other annotations we don't use which are transitive deps of deps exclude group: 'com.google.code.findbugs' exclude group: 'com.google.errorprone' exclude group: 'com.google.j2objc' exclude group: 'org.jetbrains', module: 'annotations' // Used by ANTLR runtime, has a lot of IL8N related files which we don't use. // Removing this dependency doesn't inhibit the behavior of libraries using the // runtime in practice though. exclude group: 'com.ibm.icu' } // ========================== COMPILE ========================== // https://docs.gradle.org/current/userguide/toolchains.html // gradlew -q javaToolchains - see the list of detected toolchains. java { toolchain { languageVersion = JavaLanguageVersion.of(System.getenv('TARGET_VERSION') ?: '22') } } // Append options for unchecked/deprecation tasks.withType(JavaCompile).configureEach { options.compilerArgs << '-Xlint:unchecked' << '-Xlint:deprecation' << '-g' << '-parameters' options.encoding = 'UTF-8' options.incremental = true } // Enable automatic generation of null checks on annotated methods afterEvaluate { Project p -> p.plugins.apply('gov.tak.gradle.plugins.checker-processor') } // ========================== TESTING ========================== // All modules should have the same test framework setup. test { useJUnitPlatform() // Required for Mockito in newer JDK's which disable useful features by default for 'integrity' reasons. jvmArgs '-XX:+EnableDynamicAgentLoading' systemProperty 'junit.jupiter.execution.parallel.enabled', true systemProperty 'junit.jupiter.execution.parallel.mode.default', 'concurrent' testLogging { showStandardStreams = true events "passed", "skipped", "failed" } } // All modules with Java components should share the same test dependencies. plugins.withType(JavaPlugin).configureEach { dependencies { testImplementation(libs.junit.api) testImplementation(libs.junit.params) testImplementation(libs.mockito) testImplementation(libs.assertj) testRuntimeOnly(libs.junit.engine) testRuntimeOnly(libs.junit.launcher) } } // Need to tell any test-fixture-plugin to include dependencies // in its own configuration. Otherwise it can get confused. plugins.withType(JavaTestFixturesPlugin).configureEach { dependencies { testFixturesApi(libs.junit.api) testFixturesApi(libs.junit.params) testFixturesApi(libs.mockito) } } // Configure report outputs, and jacoco packages to target. tasks.withType(Test).configureEach { reports.html.required = false reports.junitXml.required = true // We want to cover all recaf classes, but not the test classes themselves (or auto-gen classes). // The exclusion list is applied after the inclusion list, so this ends up working out. jacoco { includes = ['software/coley/recaf/**'] excludes = ['software/coley/recaf/**Test**', 'software/coley/recaf/test/**', '**/**WeldClientProxy'] } } // Setup artifact publishing to maven local publishing { publications { mavenJava(MavenPublication) { from components.java } } repositories { mavenLocal() } } } // Always emit HTML & XML aggregate reports jacocoAggregation { outputHtml = true outputXml = true } // Build aggregate report for test coverage when subproject 'test' tasks complete. // But only do so when the 'test' tasks have executed. // You can skip tests by specifying '-x test' in your gradle task arguments. tasks.register('test') { dependsOn(subprojects.test) doLast { if (subprojects.test.stream().anyMatch(Task::getDidWork)) buildJacocoAggregate.execute() } } tasks.register('build') { // Build will run tests, unless skipped by '-x test'. // Even if skipped, this will still lead to the subproject build tasks being executed, such as: // - recaf-ui:shadowJar dependsOn(tasks.named('test')) } ================================================ FILE: codecov.yml ================================================ coverage: precision: 2 round: down status: project: default: informational: true patch: default: informational: true ignore: - "**/src/test/" - "**/src/testFixtures" ================================================ FILE: docs/README.md ================================================ # Github pages directory Github pages requires this directory be named `docs`. If you were looking for documentation go here: - [User documentation](https://recaf.coley.software/user/index.html) - [Developer documentation](https://recaf.coley.software/dev/index.html) ================================================ FILE: docs/index.html ================================================ Recaf - The modern bytecode editor

Redirecting to recaf.coley.software

================================================ FILE: gradle/libs.versions.toml ================================================ [versions] acc-agent = "1.0.4" assertj = "3.27.3" asm = "9.9" atlantafx = "2.1.0" binary-resources = "31.3.0-alpha01.8" cafedude = "2.6.5" cdi-api = "4.1.0" cdi-impl = "6.0.2.Final" cfr = "0.152" dex-translator = "1.1.1" diffutils = "4.16" docking = "0.15.1" downgrader = "1.3.3" extra-collections = "1.7.0" extra-observables = "1.3.0" gson = "2.13.1" ikonli = "12.4.0" image-io-ext = { strictly = "3.0.2" } # newer release breaks ico plugin image-io-ext-ico = "3.0.2" instrument-server = "1.5.0" jackson = "2.18.2" jakarta-annotation = "3.0.0" jasm = "aacebfa8b0" jelf = "0.10.0" jlinker = "1.0.7" # We could update, but I don't feel like rewriting the callgraph jphantom = "1.4.4" junit = "5.13.4" junit-launch = "1.13.4" jsvg = "2.0.0" lljzip = "2.8.1" logback-classic = { strictly = "1.4.11" } # newer releases break in jar releases mapping-io = "0.7.0" mockito = "5.19.0" natural-order = "1.1" picocli = "4.7.7" pe = "2.2.3" procyon = "0.6.0" reactfx = { strictly = "2.0-M5" } # won't get updates, dead regex = "0.1.19" # richtextfx = "0.11.3" richtextfx = "cbdcbd1440" treemapfx = "1.1.0" vineflower = "1.11.2" sourcesolver = "1.1.9" # Plugins benmanes-versions = "0.52.0" coverage-report-aggregator = "1.3.2" checker-processor = "2.0.4" javafx-plugin = "0.1.0" shadow = "9.1.0" peterabeles-gversion = "1.10.3" [libraries] acc-agent = { module = "software.coley:javafx-access-agent", version.ref = "acc-agent" } assertj = { module = "org.assertj:assertj-core", version.ref = "assertj" } asm-core = { module = "org.ow2.asm:asm", version.ref = "asm" } asm-analysis = { module = "org.ow2.asm:asm-analysis", version.ref = "asm" } asm-commons = { module = "org.ow2.asm:asm-commons", version.ref = "asm" } asm-tree = { module = "org.ow2.asm:asm-tree", version.ref = "asm" } asm-util = { module = "org.ow2.asm:asm-util", version.ref = "asm" } atlantafx = { module = "io.github.mkpaz:atlantafx-base", version.ref = "atlantafx" } # Use our fork of Android's release with fixes and less transitive dependencies binary-resources = { module = "com.github.Col-E:binary-resources", version.ref = "binary-resources" } cafedude = { module = "software.coley:cafedude-core", version.ref = "cafedude" } cdi-api = { module = "jakarta.enterprise:jakarta.enterprise.cdi-api", version.ref = "cdi-api" } cdi-impl = { module = "org.jboss.weld.se:weld-se-core", version.ref = "cdi-impl" } cfr = { module = "org.benf:cfr", version.ref = "cfr" } dex-translator = { module = "software.coley:dex-translator", version.ref = "dex-translator" } diffutils = { module = "io.github.java-diff-utils:java-diff-utils", version.ref = "diffutils" } docking = { module = "software.coley:bento-fx", version.ref = "docking" } downgrader-core = { module = "xyz.wagyourtail.jvmdowngrader:jvmdowngrader", version.ref = "downgrader" } downgrader-impls = { module = "xyz.wagyourtail.jvmdowngrader:jvmdowngrader-java-api", version.ref = "downgrader" } extra-collections = { module = "software.coley:extra-collections", version.ref = "extra-collections" } extra-observables = { module = "software.coley:extra-observables", version.ref = "extra-observables" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" } ikonli-javafx = { module = "org.kordamp.ikonli:ikonli-javafx", version.ref = "ikonli" } ikonli-pack = { module = "org.kordamp.ikonli:ikonli-carbonicons-pack", version.ref = "ikonli" } image-io-ext = { module = "com.twelvemonkeys.imageio:imageio-core", version.ref = "image-io-ext" } image-io-ext-ico = { module = "com.twelvemonkeys.imageio:imageio-ico", version.ref = "image-io-ext-ico" } instrument-server = { module = "software.coley:instrumentation-server", version.ref = "instrument-server" } jackson = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-xml", version.ref = "jackson" } jakarta-annotation = { module = "jakarta.annotation:jakarta.annotation-api", version.ref = "jakarta-annotation" } jasm-composistion-jvm = { module = "com.github.jumanji144.Jasm:jasm-composition-jvm", version.ref = "jasm" } jasm-core = { module = "com.github.jumanji144.Jasm:jasm-core", version.ref = "jasm" } jelf = { module = "net.fornwall:jelf", version.ref = "jelf" } jlinker = { module = "com.github.xxDark:jlinker", version.ref = "jlinker" } jphantom = { module = "com.github.Col-E:jphantom", version.ref = "jphantom" } junit-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" } junit-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" } junit-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" } junit-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit-launch" } jsvg = { module = "com.github.weisj:jsvg", version.ref = "jsvg" } lljzip = { module = "software.coley:lljzip", version.ref = "lljzip" } logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback-classic" } mapping-io = { module = "net.fabricmc:mapping-io", version.ref = "mapping-io" } mockito = { module = "org.mockito:mockito-core", version.ref = "mockito" } natural-order = { module = "net.grey-panther:natural-comparator", version.ref = "natural-order" } picocli = { module = "info.picocli:picocli", version.ref = "picocli" } pe = { module = "com.github.cademtz:JavaPeParser", version.ref = "pe" } procyon = { module = "org.bitbucket.mstrobel:procyon-compilertools", version.ref = "procyon" } reactfx = { module = "org.reactfx:reactfx", version.ref = "reactfx" } regex = { module = "com.github.tommyettinger:regexodus", version.ref = "regex" } # richtextfx = { module = "org.fxmisc.richtext:richtextfx", version.ref = "richtextfx" } richtextfx = { module = "com.github.Col-E:RichTextFX", version.ref = "richtextfx" } treemapfx = { module = "software.coley:treemap-fx", version.ref = "treemapfx" } vineflower = { module = "org.vineflower:vineflower", version.ref = "vineflower" } sourcesolver = { module = "software.coley:source-solver", version.ref = "sourcesolver" } [bundles] asm = [ "asm-core", "asm-analysis", "asm-commons", "asm-tree", "asm-util", ] jasm = [ "jasm-core", "jasm-composistion-jvm", ] downgrader = [ "downgrader-core", "downgrader-impls" ] cdi = [ "cdi-api", "cdi-impl", ] logging = [ "logback-classic" ] ikonli = [ "ikonli-javafx", "ikonli-pack" ] image-io = [ "image-io-ext", "image-io-ext-ico" ] [plugins] benmanes-versions = { id = "com.github.ben-manes.versions", version.ref = "benmanes-versions" } coverage-report-aggregator = { id = "gov.tak.gradle.plugins.coverage-report-aggregator", version.ref = "coverage-report-aggregator" } checker-processor = { id = "gov.tak.gradle.plugins.checker-processor", version.ref = "checker-processor" } javafx = { id = "org.openjfx.javafxplugin", version.ref = "javafx-plugin" } shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } peterabeles-gversion = { id = "com.peterabeles.gversion", version.ref = "peterabeles-gversion" } ================================================ FILE: gradle/tasks.gradle ================================================ import java.nio.file.Files import java.nio.file.StandardCopyOption // Copies all project dependencies (including transitive) to the local Maven repository. // This is useful for offline development, or when IntelliJ fails to resolve sources automatically. // Yes, this is AI slop, but it works. Good lord, I could not bring myself to write this by hand. tasks.register('cacheToMavenLocal') { description = 'Copies all project dependencies (including transitive) to the local Maven repository' group = 'dependencies' doLast { def processedComponents = new HashSet() def allComponentIds = new HashSet() def allComponents = [] def componentRepositories = [:] // Map component to repository URL // Build a list of repository URLs from the project's configured repositories def repositoryUrls = [] project.repositories.each { repo -> if (repo.hasProperty('url') && repo.name != 'MavenLocal') { def repoUrl = repo.url.toString() if (!repoUrl.endsWith('/')) { repoUrl += '/' } repositoryUrls.add([name: repo.name, url: repoUrl]) println "Found repository: ${repo.name} -> ${repoUrl}" } } // First pass: collect all component IDs and determine their origin repositories configurations.each { configuration -> if (configuration.canBeResolved) { try { configuration.incoming.resolutionResult.allComponents.each { component -> if (component.moduleVersion) { allComponentIds.add(component.id) allComponents.add(component) def moduleVersion = component.moduleVersion def componentKey = "${moduleVersion.group}:${moduleVersion.name}:${moduleVersion.version}".toString() // Try to determine repository by checking which one has the artifact if (!componentRepositories.containsKey(componentKey)) { def group = moduleVersion.group def name = moduleVersion.name def version = moduleVersion.version def groupPath = group.replace('.', '/') def pomFileName = "${name}-${version}.pom" // First check local Maven repository - if it's there, we can skip remote checks def localMavenRepo = new File(System.getProperty('user.home'), '.m2/repository') def localArtifactDir = new File(localMavenRepo, "${groupPath}/${name}/${version}") def localPomFile = new File(localArtifactDir, pomFileName) if (localPomFile.exists()) { processedComponents.add(componentKey) componentRepositories.put(componentKey, [name: 'MavenLocal', url: null]) println "Detected ${componentKey} from MavenLocal (will skip)" return } else { localArtifactDir.mkdirs() } // Check each repository to see which one has this artifact for (repo in repositoryUrls) { def repoUrl = repo.url def pomUrl = "${repoUrl}${groupPath}/${name}/${version}/${pomFileName}" try { def url = new URL(pomUrl) def connection = (HttpURLConnection) url.openConnection() connection.setRequestMethod("GET") connection.setConnectTimeout(2000) connection.setReadTimeout(2000) connection.setInstanceFollowRedirects(true) def responseCode = connection.getResponseCode() if (responseCode == 200 || responseCode == 301 || responseCode == 302) { componentRepositories.put(componentKey, repo) println "Detected ${componentKey} from repository: ${repoUrl}" // Save the pom file if (!localPomFile.exists() || localPomFile.length() != connection.getContentLengthLong()) { println "Copying POM for ${componentKey} to local Maven repository" localPomFile.withOutputStream { out -> out << connection.getInputStream() } } else { println "Skipping POM for ${componentKey} - already exists" } break } } catch (Exception ignored) { // This repository doesn't have it, try next one } } // If we couldn't determine the repo, default to the first one (usually Maven Central) if (!componentRepositories.containsKey(componentKey) && !repositoryUrls.isEmpty()) { println "Could not detect repository for ${componentKey}" } } else { println "Already detected repository for ${componentKey}, skipping detection" } } } } catch (Exception e) { println "Could not resolve configuration ${configuration.name}: ${e.message}" } } } println "Found ${allComponentIds.size()} unique components to process" // Second pass: process all artifacts configurations.each { configuration -> if (configuration.canBeResolved) { try { // Use resolvedConfiguration to get all dependencies including transitive ones configuration.resolvedConfiguration.resolvedArtifacts.each { artifact -> def id = artifact.moduleVersion.id def componentKey = "${id.group}:${id.name}:${id.version}".toString() // Skip if we've already processed this component if (!processedComponents.add(componentKey)) return def group = id.group def name = id.name def version = id.version // Define the local Maven repository path def localMavenRepo = new File(System.getProperty('user.home'), '.m2/repository') def groupPath = group.replace('.', '/') def artifactDir = new File(localMavenRepo, "${groupPath}/${name}/${version}") // Create directory structure artifactDir.mkdirs() // Copy the main artifact def artifactFile = artifact.file def fileName = artifactFile.name def targetFile = new File(artifactDir, fileName) if (!targetFile.exists() || targetFile.length() != artifactFile.length()) { println "Copying ${group}:${name}:${version} (${fileName}) to local Maven repository" Files.copy( artifactFile.toPath(), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING ) } else { println "Skipping ${group}:${name}:${version} (${fileName}) - already exists" } } } catch (Exception e) { println "Could not resolve configuration ${configuration.name}: ${e.message}" } } } // Third pass: batch download sources and javadoc for all components println "\nResolving sources and javadoc..." def resolvedSources = new HashSet() try { def sourcesQuery = dependencies.createArtifactResolutionQuery() .forComponents(allComponentIds) .withArtifacts(JvmLibrary, SourcesArtifact) .execute() sourcesQuery.resolvedComponents.each { componentResult -> def id = componentResult.id // Extract module info - handle different ComponentIdentifier types def moduleId = null if (id.hasProperty('moduleIdentifier')) { moduleId = id.moduleIdentifier } else if (id.hasProperty('module')) { moduleId = id.module } if (moduleId) { def group = moduleId.group def name = moduleId.name def version = id.hasProperty('version') ? id.version : '' def localMavenRepo = new File(System.getProperty('user.home'), '.m2/repository') def groupPath = group.replace('.', '/') def artifactDir = new File(localMavenRepo, "${groupPath}/${name}/${version}") artifactDir.mkdirs() def sourceArtifacts = componentResult.getArtifacts(SourcesArtifact) sourceArtifacts.each { artifactResult -> if (artifactResult instanceof ResolvedArtifactResult) { def componentKey = "${group}:${name}:${version}".toString() resolvedSources.add(componentKey) def sourceFile = artifactResult.file def sourceFileName = "${name}-${version}-sources.jar" def targetFile = new File(artifactDir, sourceFileName) if (!targetFile.exists() || targetFile.length() != sourceFile.length()) { println "Copying ${group}:${name}:${version} sources to local Maven repository" Files.copy( sourceFile.toPath(), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING ) } else { println "Skipping ${group}:${name}:${version} sources - already exists" } } } } } } catch (Exception ex) { println "Exception while resolving sources: ${ex.message}" } // Fallback: Try to download sources from the detected repository for components that weren't resolved println "\nAttempting direct download for unresolved sources..." allComponents.each { component -> def moduleVersion = component.moduleVersion if (moduleVersion) { def group = moduleVersion.group def name = moduleVersion.name def version = moduleVersion.version def componentKey = "${group}:${name}:${version}".toString() if (!resolvedSources.contains(componentKey)) { def localMavenRepo = new File(System.getProperty('user.home'), '.m2/repository') def groupPath = group.replace('.', '/') def artifactDir = new File(localMavenRepo, "${groupPath}/${name}/${version}") artifactDir.mkdirs() def sourceFileName = "${name}-${version}-sources.jar" def targetFile = new File(artifactDir, sourceFileName) if (!targetFile.exists()) { // Get the repository this component came from def repo = componentRepositories.get(componentKey) if (repo) { def repoUrl = repo.url def artifactUrl = "${repoUrl}${groupPath}/${name}/${version}/${sourceFileName}" try { println "Attempting to download ${componentKey} sources from ${repoUrl}..." def url = new URL(artifactUrl) def connection = (HttpURLConnection) url.openConnection() connection.setRequestMethod("GET") connection.setConnectTimeout(5000) connection.setReadTimeout(30000) connection.setInstanceFollowRedirects(true) def responseCode = connection.getResponseCode() if (responseCode == 200) { targetFile.withOutputStream { out -> out << connection.getInputStream() } println "Downloaded ${componentKey} sources from ${repoUrl}" resolvedSources.add(componentKey) } else if (responseCode == 301 || responseCode == 302) { // Handle redirects def newUrl = connection.getHeaderField("Location") println "Redirected to ${newUrl}" def redirectConnection = (HttpURLConnection) new URL(newUrl).openConnection() redirectConnection.setRequestMethod("GET") redirectConnection.setConnectTimeout(5000) redirectConnection.setReadTimeout(30000) if (redirectConnection.getResponseCode() == 200) { targetFile.withOutputStream { out -> out << redirectConnection.getInputStream() } println "Downloaded ${componentKey} sources from ${repo.name} (after redirect)" resolvedSources.add(componentKey) } else { println "Sources not available for ${componentKey} at ${repo.name} (HTTP ${redirectConnection.getResponseCode()})" } } else { println "Sources not available for ${componentKey} at ${repo.name} (HTTP ${responseCode})" } } catch (Exception e) { println "Could not download sources for ${componentKey} from ${repo.name}: ${e.message}" } } else { println "Could not determine repository for ${componentKey}, skipping sources download" } } else { println "Skipping ${componentKey} sources - already exists" resolvedSources.add(componentKey) } } } } def resolvedJavadoc = new HashSet() try { def javadocQuery = dependencies.createArtifactResolutionQuery() .forComponents(allComponentIds) .withArtifacts(JvmLibrary, JavadocArtifact) .execute() javadocQuery.resolvedComponents.each { componentResult -> def id = componentResult.id // Extract module info - handle different ComponentIdentifier types def moduleId = null if (id.hasProperty('moduleIdentifier')) { moduleId = id.moduleIdentifier } else if (id.hasProperty('module')) { moduleId = id.module } if (moduleId) { def group = moduleId.group def name = moduleId.name def version = id.hasProperty('version') ? id.version : '' def localMavenRepo = new File(System.getProperty('user.home'), '.m2/repository') def groupPath = group.replace('.', '/') def artifactDir = new File(localMavenRepo, "${groupPath}/${name}/${version}") artifactDir.mkdirs() def javadocArtifacts = componentResult.getArtifacts(JavadocArtifact) javadocArtifacts.each { artifactResult -> if (artifactResult instanceof ResolvedArtifactResult) { def componentKey = "${group}:${name}:${version}".toString() resolvedJavadoc.add(componentKey) def javadocFile = artifactResult.file def javadocFileName = "${name}-${version}-javadoc.jar" def targetFile = new File(artifactDir, javadocFileName) if (!targetFile.exists() || targetFile.length() != javadocFile.length()) { println "Copying ${group}:${name}:${version} javadoc to local Maven repository" Files.copy( javadocFile.toPath(), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING ) } else { println "Skipping ${group}:${name}:${version} javadoc - already exists" } } } } } } catch (Exception ex) { println "Exception while resolving javadoc: ${ex.message}" } // Fallback: Try to download javadoc from the detected repository for components that weren't resolved println "\nAttempting direct download for unresolved javadoc..." allComponents.each { component -> def moduleVersion = component.moduleVersion if (moduleVersion) { def group = moduleVersion.group def name = moduleVersion.name def version = moduleVersion.version def componentKey = "${group}:${name}:${version}".toString() if (!resolvedJavadoc.contains(componentKey)) { def localMavenRepo = new File(System.getProperty('user.home'), '.m2/repository') def groupPath = group.replace('.', '/') def artifactDir = new File(localMavenRepo, "${groupPath}/${name}/${version}") artifactDir.mkdirs() def javadocFileName = "${name}-${version}-javadoc.jar" def targetFile = new File(artifactDir, javadocFileName) if (!targetFile.exists()) { // Get the repository this component came from def repo = componentRepositories.get(componentKey) if (repo) { def repoUrl = repo.url def artifactUrl = "${repoUrl}${groupPath}/${name}/${version}/${javadocFileName}" try { println "Attempting to download ${componentKey} javadoc from ${repo.name}..." def url = new URL(artifactUrl) def connection = (HttpURLConnection) url.openConnection() connection.setRequestMethod("GET") connection.setConnectTimeout(5000) connection.setReadTimeout(30000) connection.setInstanceFollowRedirects(true) def responseCode = connection.getResponseCode() if (responseCode == 200) { targetFile.withOutputStream { out -> out << connection.getInputStream() } println "Downloaded ${componentKey} javadoc from ${repo.name}" resolvedJavadoc.add(componentKey) } else if (responseCode == 301 || responseCode == 302) { // Handle redirects def newUrl = connection.getHeaderField("Location") println "Redirected to ${newUrl}" def redirectConnection = (HttpURLConnection) new URL(newUrl).openConnection() redirectConnection.setRequestMethod("GET") redirectConnection.setConnectTimeout(5000) redirectConnection.setReadTimeout(30000) if (redirectConnection.getResponseCode() == 200) { targetFile.withOutputStream { out -> out << redirectConnection.getInputStream() } println "Downloaded ${componentKey} javadoc from ${repo.name} (after redirect)" resolvedJavadoc.add(componentKey) } else { println "Javadoc not available for ${componentKey} at ${repo.name} (HTTP ${redirectConnection.getResponseCode()})" } } else { println "Javadoc not available for ${componentKey} at ${repo.name} (HTTP ${responseCode})" } } catch (Exception e) { println "Could not download javadoc for ${componentKey} from ${repo.name}: ${e.message}" } } else { println "Could not determine repository for ${componentKey}, skipping javadoc download" } } else { println "Skipping ${componentKey} javadoc - already exists" resolvedJavadoc.add(componentKey) } } } } println "\nAll dependencies (including transitive) and sources have been copied to the local Maven repository" println "Total unique components processed: ${processedComponents.size()}" println "Sources resolved: ${resolvedSources.size()}" println "Javadoc resolved: ${resolvedJavadoc.size()}" } } ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ org.gradle.caching=true ================================================ FILE: gradlew ================================================ #!/bin/sh # # Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit APP_NAME="Gradle" APP_BASE_NAME=${0##*/} # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Collect all arguments for the java command; # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # shell script including quotes and variable substitutions, so put them in # double quotes to make sure that they get re-expanded; and # * put everything else in single quotes, so that it's not re-expanded. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ org.gradle.wrapper.GradleWrapperMain \ "$@" # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @if "%DEBUG%" == "" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%" == "" set DIRNAME=. set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if "%ERRORLEVEL%" == "0" goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% echo. echo Please set the JAVA_HOME variable in your environment to match the echo location of your Java installation. goto fail :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell if "%ERRORLEVEL%"=="0" goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 exit /b 1 :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: jitpack.yml ================================================ jdk: - openjdk22 before_install: - sdk install java 22.0.1-zulu - sdk use java 22.0.1-zulu ================================================ FILE: libs/README.md ================================================ # Provided libraries In some circumstances, libraries are not hosted on standard Maven hosts. ## Kotlin Metadata Generally provided in a much larger dependency, this is just the portion pertaining to reading Kotlin Metadata. This minified dependency was provided by `SuperCoder79` ================================================ FILE: recaf-core/build.gradle ================================================ import java.nio.file.Files plugins { alias(libs.plugins.peterabeles.gversion) } apply plugin: 'java-library' apply plugin: 'java-test-fixtures' dependencies { api(libs.bundles.asm) api(libs.binary.resources) api(libs.cafedude) api(libs.bundles.cdi) api(libs.cfr) api(libs.dex.translator) { exclude group: 'com.android.tools' } api(libs.diffutils) api(libs.bundles.downgrader) { exclude group: 'org.ow2.asm' } api(libs.extra.collections) api(libs.extra.observables) api(libs.gson) // required by R8 (from dex-translator) api(libs.instrument.server) api(libs.jelf) api(libs.jphantom) api(libs.lljzip) api(libs.bundles.logging) api(libs.mapping.io) api(libs.natural.order) api(libs.pe) api(libs.picocli) api(libs.procyon) api(libs.jlinker) api(libs.regex) api(libs.bundles.jasm) api(libs.vineflower) api(libs.sourcesolver) // We use Jackson as a test dependency so that we can convert Android API XML models into JSON. // This way, we only need to include GSON in the final Recaf build. testImplementation(libs.jackson) } // Force generation of gversion data class when the version information is not up-to-date tasks.register('conditionalBuildConfigUpdate') { if (!isBuildConfigUpToDate()) { finalizedBy createVersionFile } } project.compileJava.dependsOn('conditionalBuildConfigUpdate') gversion { srcDir = "src/generated/java/" classPackage = "software.coley.recaf" className = "RecafBuildConfig" dateFormat = "yyyy MM/dd HH:mm" debug = true language = "java" explicitType = false annotate = false } sourceSets { // Need to add the generated class to the source set main { java { srcDirs 'src/generated/java', 'src/main/java' } } } clean { // Delete the generated gversion class when cleaning delete 'src/generated' } private boolean isBuildConfigUpToDate() { File buildConfigPath = project.file(gversion.srcDir + gversion.classPackage.replace('.', '/') + '/' + gversion.className + ".java") if (buildConfigPath.exists()) { String text = Files.readString(buildConfigPath.toPath()) if (text.contains('VERSION = "' + project.version + '"')) return true } return false } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/Bootstrap.java ================================================ package software.coley.recaf; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.inject.se.SeContainer; import org.jboss.weld.environment.se.Weld; import org.slf4j.Logger; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.cdi.EagerInitializationExtension; import java.util.function.Consumer; import static software.coley.recaf.RecafBuildConfig.*; /** * Handles creation of Recaf instance. * * @author Matt Coley */ public class Bootstrap { private static final Logger logger = Logging.get(Bootstrap.class); private static Recaf instance; private static Consumer weldConsumer; /** * @return Recaf instance. */ @Nonnull public static Recaf get() { if (instance == null) { String fmt = """ Initializing Recaf {} - Build rev: {} - Build date: {} - Build hash: {}"""; logger.info(fmt, VERSION, GIT_REVISION, GIT_DATE, GIT_SHA); long then = System.currentTimeMillis(); // Create the Recaf container try { SeContainer container = createContainer(); instance = new Recaf(container); logger.info("Recaf CDI container created in {}ms", System.currentTimeMillis() - then); } catch (Throwable t) { logger.error("Failed to create Recaf CDI container", t); ExitDebugLoggingHook.exit(ExitCodes.ERR_CDI_INIT_FAILURE); } } return instance; } /** * Must be called before invoking {@link #get()}. * * @param consumer * Consumer to operate on the CDI container producing {@link Weld} instance. */ public static void setWeldConsumer(@Nullable Consumer consumer) { weldConsumer = consumer; } @Nonnull private static SeContainer createContainer() { logger.info("Creating Recaf CDI container..."); Weld weld = new Weld("recaf"); weld.setClassLoader(Bootstrap.class.getClassLoader()); // Setup custom interceptors & extensions logger.info("CDI: Adding interceptors & extensions"); weld.addExtension(EagerInitializationExtension.getInstance()); // Setup bean discovery logger.info("CDI: Registering bean packages"); weld.addPackage(true, Recaf.class); // Handle user-defined action if (weldConsumer != null) { logger.info("CDI: Running user-defined Consumer"); weldConsumer.accept(weld); weldConsumer = null; } logger.info("CDI: Initializing..."); return weld.initialize(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/ExitCodes.java ================================================ package software.coley.recaf; /** * Exit codes for Recaf calling {@link System#exit(int)}. * * @author Matt Coley */ public class ExitCodes { public static final int SUCCESS = 0; public static final int ERR_FX_UNKNOWN = 100; public static final int ERR_FX_CLASS_NOT_FOUND = 101; public static final int ERR_FX_NO_SUCH_METHOD = 102; public static final int ERR_FX_INVOKE_TARGET = 103; public static final int ERR_FX_ACCESS_TARGET = 104; public static final int ERR_FX_OLD_VERSION = 105; public static final int ERR_FX_UNKNOWN_VERSION = 106; public static final int INTELLIJ_TERMINATION = 130; public static final int ERR_CDI_INIT_FAILURE = 150; public static final int ERR_NOT_A_JDK = 160; } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/ExitDebugLoggingHook.java ================================================ package software.coley.recaf; import com.google.common.hash.Hasher; import com.google.common.hash.Hashing; import jakarta.annotation.Nonnull; import org.slf4j.Logger; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.services.file.RecafDirectoriesConfig; import software.coley.recaf.util.JavaVersion; import software.coley.recaf.util.ReflectUtil; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.lang.module.ModuleReference; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.net.URI; import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; import java.util.Iterator; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import java.util.stream.Stream; /** * A hook registered to {@code java.lang.Shutdown} and not {@link Runtime#addShutdownHook(Thread)}. * This allows us to use the {@link StackWalker} API to detect the exit code from {@link System#exit(int)} * * @author Matt Coley */ public class ExitDebugLoggingHook { private static final int UNKNOWN_CODE = -1337420; private static final Logger logger = Logging.get(ExitDebugLoggingHook.class); private static int exitCode = UNKNOWN_CODE; private static boolean printConfigs; private static Thread mainThread; private static MethodHandles.Lookup lookup; /** * Register the shutdown hook. */ public static void register() { lookup = ReflectUtil.lookup(); try { // We use this instead of the Runtime shutdown hook thread because this will run on the same thread // as the call to System.exit(int) Class shutdown = lookup.findClass("java.lang.Shutdown"); MethodHandle add = lookup.findStatic(shutdown, "add", MethodType.methodType(void.class, int.class, boolean.class, Runnable.class)); add.invoke(9, false, (Runnable) ExitDebugLoggingHook::run); } catch (Throwable t) { logger.error("Failed to add exit-hooking debug dumping shutdown hook", t); // Use fallback shutdown hook which checks for manual exit codes being set. Runtime.getRuntime().addShutdownHook(new Thread(() -> { if (exitCode != UNKNOWN_CODE) handle(exitCode); })); } } private static void run() { // If we've set the exit code manually, use that. if (exitCode != UNKNOWN_CODE) { handle(exitCode); return; } // We didn't do the exit, so lets try and see if we can figure out who did it. try { AtomicBoolean visited = new AtomicBoolean(); Class shutdown = lookup.findClass("java.lang.Shutdown"); Class stackFrameClass = Class.forName("java.lang.LiveStackFrame"); MethodHandle getLocals = lookup.findVirtual(stackFrameClass, "getLocals", MethodType.methodType(Object[].class)) .asType(MethodType.methodType(Object[].class, Object.class)); Method getStackWalker = stackFrameClass.getDeclaredMethod("getStackWalker", Set.class); getStackWalker.setAccessible(true); StackWalker stackWalker = (StackWalker) getStackWalker.invoke(null, Set.of(StackWalker.Option.RETAIN_CLASS_REFERENCE, StackWalker.Option.SHOW_HIDDEN_FRAMES)); stackWalker.forEach(frame -> { try { if (!visited.get() && frame.getDeclaringClass() == shutdown && frame.getMethodName().equals("exit")) { Object[] locals = (Object[]) getLocals.invoke(frame); String local0 = locals[0].toString().replaceAll("\\D+", ""); int exit = (int) Long.parseLong(local0); // Need to parse as long, cast to int handle(exit); visited.set(true); } } catch (Throwable t) { throw new IllegalStateException(t); } }); } catch (Throwable t) { // If this cursed abomination breaks, we want to know about it logger.error(""" Failed to detect application exit code. Please report that the exit debugger has failed. https://github.com/Col-E/Recaf/issues/new?labels=bug&title=Error%20Debugger%20Hook%20fails%20on%20Java%20{V} """.replace("{V}", String.valueOf(JavaVersion.get())), t); } } private static void handle(int code) { // Skip on successful closure if (code == ExitCodes.SUCCESS || code == ExitCodes.INTELLIJ_TERMINATION) return; if (code == UNKNOWN_CODE) System.out.println("Exit code: "); else System.out.println("Exit code: " + code); System.out.println("Java"); System.out.println(" - Version (Runtime): " + System.getProperty("java.runtime.version", "")); System.out.println(" - Version (Raw): " + JavaVersion.get()); System.out.println(" - Vendor: " + System.getProperty("java.vm.vendor", "")); System.out.println(" - Home: " + System.getProperty("java.home", "")); System.out.println("JavaFX"); System.out.println(" - Version (Runtime): " + System.getProperty("javafx.runtime.version", "")); System.out.println(" - Version (Raw): " + System.getProperty("javafx.version", "")); { ClassLoader loader = ExitDebugLoggingHook.class.getClassLoader(); String javafxClass = "javafx/beans/Observable.class"; try { Iterator iterator = loader.getResources(javafxClass).asIterator(); if (!iterator.hasNext()) { System.out.println(" - Location: not found"); } else { URL url = iterator.next(); if (!iterator.hasNext()) { System.out.println(" - Location: " + url); } else { System.out.println(" - Location (likely): " + url); do { System.out.println(" - Location (seen): " + url); } while (iterator.hasNext()); } } } catch (Exception ex) { System.out.println(" - Location: "); } } System.out.println("Operating System"); System.out.println(" - Name: " + System.getProperty("os.name")); System.out.println(" - Version: " + System.getProperty("os.version")); System.out.println(" - Architecture: " + System.getProperty("os.arch")); System.out.println(" - Processors: " + Runtime.getRuntime().availableProcessors()); System.out.println(" - Path Separator: " + File.pathSeparator); System.out.println("Recaf"); System.out.println(" - Version: " + RecafBuildConfig.VERSION); System.out.println(" - Build hash: " + RecafBuildConfig.GIT_SHA); System.out.println(" - Build date: " + RecafBuildConfig.GIT_DATE); String command = System.getProperty("sun.java.command", ""); if (command != null) { System.out.println("Launch"); System.out.println(" - Args: " + command); } String[] classPath = System.getProperty("java.class.path").split(File.pathSeparator); System.out.println("Classpath:"); for (String pathEntry : classPath) { File file = new File(pathEntry); if (file.isFile()) { System.out.println(" - File: " + pathEntry); try { System.out.println(" - SHA1: " + createSha1(file)); } catch (Exception ex) { System.out.println(" - SHA1: "); } } else if (file.isDirectory()) { System.out.println(" - Directory: " + pathEntry); } } System.out.println("Boot class loader:"); dumpBootstrapClassLoader(); System.out.println("Platform class loader:"); dumpBuiltinClassLoader(ClassLoader.getPlatformClassLoader()); Path root = RecafDirectoriesConfig.createBaseDirectory().resolve("config"); if (printConfigs && Files.isDirectory(root)) { System.out.println("Configs"); try (Stream stream = Files.walk(root)) { stream.filter(path -> path.getFileName().toString().endsWith("-config.json")).forEach(path -> { String configName = path.getFileName().toString(); // Skip certain configs if (configName.contains("service.ui.bind-")) return; if (configName.contains("service.ui.snippets-config")) return; if (configName.contains("service.io.recent-workspaces-config")) return; if (configName.contains("service.decompile.impl.decompiler-")) return; System.out.println(" - " + configName + ":"); try { String indent = " "; String configJson = Files.readString(path); String indented = indent + Arrays.stream(configJson.split("\n")) .collect(Collectors.joining("\n" + indent)); System.out.println(indented); } catch (Throwable t) { System.out.println(" - "); } }); } catch (Exception ex) { System.out.println(" - "); } } System.out.println("Threads"); Thread.getAllStackTraces().forEach((thread, trace) -> { System.out.println(" - " + thread.getName() + " [" + thread.getState().name() + "]"); for (StackTraceElement element : trace) { System.out.println(" - " + element); } }); } @SuppressWarnings("unchecked") private static void dumpBuiltinClassLoader(ClassLoader loader) { try { Class c = Class.forName("jdk.internal.loader.BuiltinClassLoader"); Field nameToModuleField = c.getDeclaredField("nameToModule"); nameToModuleField.setAccessible(true); Map mdouleMap = (Map) nameToModuleField.get(loader); for (Map.Entry e : mdouleMap.entrySet()) { ModuleReference moduleReference = e.getValue(); System.out.printf("%s located at %s%n", moduleReference.descriptor().toNameAndVersion(), moduleReference.location() .map(URI::toString) .orElse("Unknown")); } } catch (Exception ex) { System.out.printf("dumpBuiltinClassLoader(%s) - %n", loader); } } private static void dumpBootstrapClassLoader() { try { Class c = Class.forName("jdk.internal.loader.ClassLoaders"); Method bootLoaderMethod = c.getDeclaredMethod("bootLoader"); bootLoaderMethod.setAccessible(true); dumpBuiltinClassLoader((ClassLoader) bootLoaderMethod.invoke(null)); } catch (Exception ex) { System.out.println("dumpBootstrapClassLoader - "); } } @Nonnull @SuppressWarnings("all") private static String createSha1(@Nonnull File file) throws Exception { long length = file.length(); if (length > Integer.MAX_VALUE) throw new IOException("File too large to hash"); Hasher hasher = Hashing.sha1().newHasher((int) length); try (InputStream fis = new FileInputStream(file)) { int n = 0; byte[] buffer = new byte[8192]; while (n != -1) { n = fis.read(buffer); if (n > 0) hasher.putBytes(buffer, 0, n); } return hasher.hash().toString(); } } public static void exit(int exitCode) { ExitDebugLoggingHook.exitCode = exitCode; System.exit(exitCode); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/Recaf.java ================================================ package software.coley.recaf; import jakarta.annotation.Nonnull; import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.se.SeContainer; import java.lang.annotation.Annotation; import java.util.Locale; /** * Recaf application instance. * * @author Matt Coley */ public class Recaf { private static final Annotation[] NO_QUALIFIERS = new Annotation[0]; private final SeContainer container; /** * @param container * Container instance for bean management. */ public Recaf(@Nonnull SeContainer container) { this.container = container; } /** * @return The CDI container. */ public SeContainer getContainer() { return container; } /** * @param type * Type to get an {@link Instance} of. * @param * Instance type. * * @return Instance accessor for bean of the given type. */ @Nonnull public Instance instance(@Nonnull Class type) { return instance(type, NO_QUALIFIERS); } /** * @param type * Type to get an {@link Instance} of. * @param qualifiers * Qualifiers to narrow down an option if multiple candidate instances exist for the type. * @param * Instance type. * * @return Instance accessor for bean of the given type. */ @Nonnull public Instance instance(@Nonnull Class type, Annotation... qualifiers) { return container.select(type, qualifiers); } /** * @param type * Type to get instance of. * @param * Instance type. * * @return Instance of type (Wrapped in a proxy). */ @Nonnull public T get(@Nonnull Class type) { return get(type, NO_QUALIFIERS); } /** * @param type * Type to get instance of. * @param qualifiers * Qualifiers to narrow down an option if multiple candidate instances exist for the type. * @param * Instance type. * * @return Instance of type (Wrapped in a proxy). */ @Nonnull public T get(@Nonnull Class type, Annotation... qualifiers) { return instance(type, qualifiers).get(); } static { // Enforce US locale just in case we have some string formatting susceptible to this "feature": // https://mattryall.net/blog/the-infamous-turkish-locale-bug Locale.setDefault(Locale.US); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/RecafConstants.java ================================================ package software.coley.recaf; import org.objectweb.asm.Opcodes; /** * Common constants. * * @author Matt Coley */ public final class RecafConstants { private RecafConstants() { } /** * @return Current ASM version. */ public static int getAsmVersion() { return Opcodes.ASM9; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/analytics/SystemInformation.java ================================================ package software.coley.recaf.analytics; import jakarta.annotation.Nullable; import org.slf4j.Logger; import java.io.StringWriter; import java.util.Map; import java.util.TreeMap; /** * Common properties pulled at runtime from {@link System#getProperty(String)} * that are useful for tracking and logging. * * @author Matt Coley */ public class SystemInformation { private static final String KEY_OS_NAME = "os.name"; private static final String KEY_OS_ARCH = "os.arch"; private static final String KEY_OS_VERSION = "os.version"; private static final String KEY_OS_ARCH_BITS = "os.bitness"; private static final String KEY_OS_PROCESSORS = "os.processors"; private static final String KEY_JAVA_VERSION = "java.version"; private static final String KEY_JAVA_VM_NAME = "java.vm.version"; private static final String KEY_JAVA_VM_VENDOR = "java.vm.vendor"; private static final String KEY_JAVA_HOME = "java.home"; public static final String OS_NAME = System.getProperty(KEY_OS_NAME); public static final String OS_ARCH = System.getProperty(KEY_OS_ARCH); public static final int OS_ARCH_BITS = determineBitness(); public static final String OS_VERSION = System.getProperty(KEY_OS_VERSION); public static final String JAVA_VERSION = System.getProperty(KEY_JAVA_VERSION); public static final String JAVA_VM_NAME = System.getProperty(KEY_JAVA_VM_NAME); public static final String JAVA_VM_VENDOR = System.getProperty(KEY_JAVA_VM_VENDOR); public static final String JAVA_HOME = System.getProperty(KEY_JAVA_HOME); private static final Map ALL_PROPERTIES = new TreeMap<>() { { put(KEY_OS_NAME, OS_NAME); put(KEY_OS_ARCH, OS_ARCH); put(KEY_OS_ARCH_BITS, String.valueOf(OS_ARCH_BITS)); put(KEY_OS_PROCESSORS, String.valueOf(Runtime.getRuntime().availableProcessors())); put(KEY_OS_VERSION, OS_VERSION); put(KEY_JAVA_VERSION, JAVA_VERSION); put(KEY_JAVA_VM_NAME, JAVA_VM_NAME); put(KEY_JAVA_VM_VENDOR, JAVA_VM_VENDOR); put(KEY_JAVA_HOME, JAVA_HOME); } }; /** * @return {@code 64} or {@code 32} dependent on the {@link #OS_ARCH}. */ private static int determineBitness() { // Parse from specification value, which is commonly defined. String bitness = System.getProperty("sun.arch.data.model", ""); if (bitness.matches("[0-9]{2}")) return Integer.parseInt(bitness, 10); // Parse from IBM value, used on IBM releases. bitness = System.getProperty("com.ibm.vm.bitmode", ""); if (bitness.matches("[0-9]{2}")) return Integer.parseInt(bitness, 10); return OS_ARCH.contains("64") ? 64 : 32; } /** * Dump all properties into the given logger. * * @param logger * Logger to dump into. */ public static void dump(@Nullable Logger logger) { if (logger != null) ALL_PROPERTIES.forEach((key, value) -> logger.debug("{} = {}", key, value)); } /** * Dump all properties into the given writer. * * @param writer * Writer to dump into. */ public static void dump(@Nullable StringWriter writer) { if (writer != null) ALL_PROPERTIES.forEach((key, value) -> writer.append(String.format("%s = %s\n", key, value))); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/analytics/logging/DebuggingLogger.java ================================================ package software.coley.recaf.analytics.logging; import jakarta.annotation.Nonnull; import org.slf4j.Logger; import software.coley.recaf.util.ExcludeFromJacocoGeneratedReport; import java.util.function.Consumer; /** * Used for verbose logging that we normally would not want to capture due to excessiveness. * But in the case where we want to enable it for local testing, its available. * * @author Matt Coley */ @ExcludeFromJacocoGeneratedReport(justification = "Logging not relevant for test coverage") public interface DebuggingLogger extends Logger { boolean DEBUG = System.getenv("RECAF_DEBUG") != null; /** * Only do the given action when manual debugging is enabled. * * @param action * Call onto self. */ default void debugging(@Nonnull Consumer action) { if (DEBUG) action.accept(this); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/analytics/logging/InterceptingLogger.java ================================================ package software.coley.recaf.analytics.logging; import jakarta.annotation.Nonnull; import org.slf4j.Logger; import org.slf4j.Marker; import org.slf4j.event.Level; import software.coley.recaf.util.ExcludeFromJacocoGeneratedReport; import java.util.regex.Matcher; /** * A forwarding logger that lets us intercept compiled messages. * * @author Matt Coley */ @ExcludeFromJacocoGeneratedReport(justification = "Logging not relevant for test coverage") public abstract class InterceptingLogger implements DebuggingLogger { private final Logger backing; /** * @param backing * Backing logger to send info to. */ protected InterceptingLogger(@Nonnull Logger backing) { this.backing = backing; } /** * Intercept logging. * * @param level * Level logged. * @param message * Message logged. */ public abstract void intercept(Level level, String message); /** * Intercept throwable logging. * * @param level * Level logged. * @param message * Message logged. * @param t * Item thrown. */ public abstract void intercept(Level level, String message, Throwable t); @Override public String getName() { return backing.getName(); } @Override public boolean isTraceEnabled() { return backing.isTraceEnabled(); } @Override public boolean isTraceEnabled(Marker marker) { return backing.isTraceEnabled(marker); } @Override public void trace(String msg) { backing.trace(msg); if (isTraceEnabled()) intercept(Level.TRACE, msg); } @Override public void trace(String format, Object arg) { trace(format, new Object[]{arg}); } @Override public void trace(String format, Object arg1, Object arg2) { trace(format, new Object[]{arg1, arg2}); } @Override public void trace(String format, Object... arguments) { backing.trace(format, arguments); if (isTraceEnabled()) intercept(Level.TRACE, compile(format, arguments)); } @Override public void trace(String msg, Throwable t) { backing.trace(msg, t); if (isTraceEnabled()) intercept(Level.TRACE, msg, t); } @Override public void trace(Marker marker, String msg) { backing.trace(marker, msg); if (isTraceEnabled()) intercept(Level.TRACE, msg); } @Override public void trace(Marker marker, String format, Object arg) { trace(marker, format, new Object[]{arg}); } @Override public void trace(Marker marker, String format, Object arg1, Object arg2) { trace(marker, format, new Object[]{arg1, arg2}); } @Override public void trace(Marker marker, String format, Object... arguments) { backing.trace(marker, format, arguments); if (isTraceEnabled()) intercept(Level.TRACE, compile(format, arguments)); } @Override public void trace(Marker marker, String msg, Throwable t) { backing.trace(marker, msg, t); if (isTraceEnabled()) intercept(Level.TRACE, msg, t); } @Override public boolean isDebugEnabled() { return backing.isDebugEnabled(); } @Override public boolean isDebugEnabled(Marker marker) { return backing.isDebugEnabled(marker); } @Override public void debug(String msg) { backing.debug(msg); if (isDebugEnabled()) intercept(Level.DEBUG, msg); } @Override public void debug(String format, Object arg) { debug(format, new Object[]{arg}); } @Override public void debug(String format, Object arg1, Object arg2) { debug(format, new Object[]{arg1, arg2}); } @Override public void debug(String format, Object... arguments) { backing.debug(format, arguments); if (isDebugEnabled()) intercept(Level.DEBUG, compile(format, arguments)); } @Override public void debug(String msg, Throwable t) { backing.debug(msg, t); if (isDebugEnabled()) intercept(Level.DEBUG, msg, t); } @Override public void debug(Marker marker, String msg) { backing.debug(marker, msg); if (isDebugEnabled()) intercept(Level.DEBUG, msg); } @Override public void debug(Marker marker, String format, Object arg) { debug(marker, format, new Object[]{arg}); } @Override public void debug(Marker marker, String format, Object arg1, Object arg2) { debug(marker, format, new Object[]{arg1, arg2}); } @Override public void debug(Marker marker, String format, Object... arguments) { backing.debug(marker, format, arguments); if (isDebugEnabled()) intercept(Level.DEBUG, compile(format, arguments)); } @Override public void debug(Marker marker, String msg, Throwable t) { backing.debug(marker, msg, t); if (isDebugEnabled()) intercept(Level.DEBUG, msg, t); } @Override public boolean isInfoEnabled() { return backing.isInfoEnabled(); } @Override public boolean isInfoEnabled(Marker marker) { return backing.isInfoEnabled(marker); } @Override public void info(String msg) { backing.info(msg); if (isInfoEnabled()) intercept(Level.INFO, msg); } @Override public void info(String format, Object arg) { info(format, new Object[]{arg}); } @Override public void info(String format, Object arg1, Object arg2) { info(format, new Object[]{arg1, arg2}); } @Override public void info(String format, Object... arguments) { backing.info(format, arguments); if (isInfoEnabled()) intercept(Level.INFO, compile(format, arguments)); } @Override public void info(String msg, Throwable t) { backing.info(msg, t); if (isInfoEnabled()) intercept(Level.INFO, msg, t); } @Override public void info(Marker marker, String msg) { backing.info(marker, msg); if (isInfoEnabled()) intercept(Level.INFO, msg); } @Override public void info(Marker marker, String format, Object arg) { info(marker, format, new Object[]{arg}); } @Override public void info(Marker marker, String format, Object arg1, Object arg2) { info(marker, format, new Object[]{arg1, arg2}); } @Override public void info(Marker marker, String format, Object... arguments) { backing.info(marker, format, arguments); if (isInfoEnabled()) intercept(Level.INFO, compile(format, arguments)); } @Override public void info(Marker marker, String msg, Throwable t) { backing.info(marker, msg, t); if (isInfoEnabled()) intercept(Level.INFO, msg, t); } @Override public boolean isWarnEnabled() { return backing.isWarnEnabled(); } @Override public boolean isWarnEnabled(Marker marker) { return backing.isWarnEnabled(marker); } @Override public void warn(String msg) { backing.warn(msg); if (isWarnEnabled()) intercept(Level.WARN, msg); } @Override public void warn(String format, Object arg) { warn(format, new Object[]{arg}); } @Override public void warn(String format, Object arg1, Object arg2) { warn(format, new Object[]{arg1, arg2}); } @Override public void warn(String format, Object... arguments) { backing.warn(format, arguments); if (isWarnEnabled()) intercept(Level.WARN, compile(format, arguments)); } @Override public void warn(String msg, Throwable t) { backing.warn(msg, t); if (isWarnEnabled()) intercept(Level.WARN, msg, t); } @Override public void warn(Marker marker, String msg) { backing.warn(marker, msg); if (isWarnEnabled()) intercept(Level.WARN, msg); } @Override public void warn(Marker marker, String format, Object arg) { warn(marker, format, new Object[]{arg}); } @Override public void warn(Marker marker, String format, Object arg1, Object arg2) { warn(marker, format, new Object[]{arg1, arg2}); } @Override public void warn(Marker marker, String format, Object... arguments) { backing.warn(marker, format, arguments); if (isWarnEnabled()) intercept(Level.WARN, compile(format, arguments)); } @Override public void warn(Marker marker, String msg, Throwable t) { backing.warn(marker, msg, t); if (isWarnEnabled()) intercept(Level.WARN, msg, t); } @Override public boolean isErrorEnabled() { return backing.isErrorEnabled(); } @Override public boolean isErrorEnabled(Marker marker) { return backing.isErrorEnabled(marker); } @Override public void error(String msg) { backing.error(msg); if (isErrorEnabled()) intercept(Level.ERROR, msg); } @Override public void error(String format, Object arg) { error(format, new Object[]{arg}); } @Override public void error(String format, Object arg1, Object arg2) { error(format, new Object[]{arg1, arg2}); } @Override public void error(String format, Object... arguments) { backing.error(format, arguments); if (isErrorEnabled()) intercept(Level.ERROR, compile(format, arguments)); } @Override public void error(String msg, Throwable t) { backing.error(msg, t); if (isErrorEnabled()) intercept(Level.ERROR, msg, t); } @Override public void error(Marker marker, String msg) { backing.error(marker, msg); if (isErrorEnabled()) intercept(Level.ERROR, msg); } @Override public void error(Marker marker, String format, Object arg) { error(marker, format, new Object[]{arg}); } @Override public void error(Marker marker, String format, Object arg1, Object arg2) { error(marker, format, new Object[]{arg1, arg2}); } @Override public void error(Marker marker, String format, Object... arguments) { backing.error(marker, format, arguments); if (isErrorEnabled()) intercept(Level.ERROR, compile(format, arguments)); } @Override public void error(Marker marker, String msg, Throwable t) { backing.error(marker, msg, t); if (isErrorEnabled()) intercept(Level.ERROR, msg, t); } private static String compile(String message, Object[] arguments) { int i = 0; while (message.contains("{}")) { // Failsafe, shouldn't occur if logging is written correctly if (i == arguments.length) return message; // Replace arg in pattern Object arg = arguments[i]; String argStr = arg == null ? "null" : arg.toString(); message = message.replaceFirst("\\{}", Matcher.quoteReplacement(argStr)); i++; } return message; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/analytics/logging/LogConsumer.java ================================================ package software.coley.recaf.analytics.logging; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.slf4j.event.Level; /** * Triple-argument consumer for taking in log messages. * * @param * Log content. * * @author Matt Coley */ public interface LogConsumer { /** * @param loggerName * Name of logger message applies to. * @param level * Log level of message. * @param messageContent * Content of message. */ void accept(@Nonnull String loggerName, @Nonnull Level level, @Nullable T messageContent); /** * @param loggerName * Name of logger message applies to. * @param level * Log level of message. * @param messageContent * Content of message. * @param throwable * Associated thrown exception. */ void accept(@Nonnull String loggerName, @Nonnull Level level, @Nullable T messageContent, @Nullable Throwable throwable); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/analytics/logging/Logging.java ================================================ package software.coley.recaf.analytics.logging; import ch.qos.logback.classic.LoggerContext; import ch.qos.logback.classic.encoder.PatternLayoutEncoder; import ch.qos.logback.core.FileAppender; import jakarta.annotation.Nonnull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.event.Level; import software.coley.recaf.util.ExcludeFromJacocoGeneratedReport; import java.nio.file.Path; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.NavigableSet; import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import static org.slf4j.LoggerFactory.getLogger; /** * {@link LoggerFactory} wrapper that lets us intercept all logged messages. * * @author Matt Coley */ @ExcludeFromJacocoGeneratedReport(justification = "Logging not relevant for test coverage") public class Logging { private static final Map loggers = new ConcurrentHashMap<>(); private static final NavigableSet loggerKeys = Collections.synchronizedNavigableSet(new TreeSet<>()); private static final List> logConsumers = new CopyOnWriteArrayList<>(); private static Level interceptLevel = Level.INFO; /** * @return Set of the current logger keys. */ @Nonnull public static NavigableSet loggerKeys() { // We track the keys in this separate set so that we can retrieve them // in sorted order without needing to wrap in 'new TreeSet' every time. return Collections.unmodifiableNavigableSet(loggerKeys); } /** * @param name * Logger name. * * @return Logger associated with name. */ @Nonnull public static DebuggingLogger get(@Nonnull String name) { return loggers.computeIfAbsent(name, k -> intercept(k, getLogger(k))); } /** * @param cls * Logger class key. * * @return Logger associated with class. */ @Nonnull public static DebuggingLogger get(@Nonnull Class cls) { return loggers.computeIfAbsent(cls.getName(), k -> intercept(k, getLogger(k))); } /** * @param consumer * New log message consumer. */ public static void addLogConsumer(@Nonnull LogConsumer consumer) { logConsumers.add(consumer); } /** * @param consumer * Log message consumer to remove. */ public static void removeLogConsumer(@Nonnull LogConsumer consumer) { logConsumers.remove(consumer); } /** * Sets the target level for log interception. This affects what messages {@link LogConsumer}s receive. * * @param level * New target level. */ public static void setInterceptLevel(@Nonnull Level level) { interceptLevel = level; } /** * Registers a file appender for all log calls. * * @param path * Path to file to append to. */ @SuppressWarnings({"unchecked", "rawtypes"}) public static void addFileAppender(@Nonnull Path path) { // We do it this way so the file path can be set at runtime. LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); FileAppender fileAppender = new FileAppender<>(); fileAppender.addFilter(new RecafLoggingFilter(ch.qos.logback.classic.Level.ALL)); fileAppender.setFile(path.toString()); fileAppender.setContext(loggerContext); fileAppender.setPrudent(true); fileAppender.setAppend(true); fileAppender.setImmediateFlush(true); // Pattern PatternLayoutEncoder encoder = new PatternLayoutEncoder(); encoder.setContext(loggerContext); encoder.setPattern("%d{HH:mm:ss.SSS} [%logger{0}/%thread] %-5level: %msg%n"); encoder.start(); fileAppender.setEncoder(encoder); // Start file appender fileAppender.start(); // Create logger ch.qos.logback.classic.Logger logbackLogger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(ch.qos.logback.classic.Logger.ROOT_LOGGER_NAME); logbackLogger.addAppender(fileAppender); logbackLogger.setAdditive(false); } @Nonnull private static DebuggingLogger intercept(@Nonnull String name, @Nonnull Logger logger) { loggerKeys.add(name); return new InterceptingLogger(logger) { @Override public void intercept(@Nonnull Level level, String message) { if (interceptLevel.toInt() <= level.toInt()) logConsumers.forEach(consumer -> consumer.accept(name, level, message)); } @Override public void intercept(@Nonnull Level level, String message, Throwable t) { if (interceptLevel.toInt() <= level.toInt()) logConsumers.forEach(consumer -> consumer.accept(name, level, message, t)); } }; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/analytics/logging/RecafLoggingFilter.java ================================================ package software.coley.recaf.analytics.logging; import ch.qos.logback.classic.Level; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.filter.Filter; import ch.qos.logback.core.spi.FilterReply; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.util.ExcludeFromJacocoGeneratedReport; import java.util.Objects; import java.util.function.Supplier; /** * Logging filter impl that only allows Recaf logger calls. * * @author Matt Coley */ @ExcludeFromJacocoGeneratedReport(justification = "Logging not relevant for test coverage") public class RecafLoggingFilter extends Filter { /** Shared default level - used by auto-created instances of this filter. */ public static Level defaultLevel = Level.TRACE; /** Instance supplier of the logging level for this filter. */ private final Supplier instanceLevel; /** * No-args constructor for auto-created instances. * Will delegate the level to {@link #defaultLevel}. */ public RecafLoggingFilter() { instanceLevel = () -> defaultLevel; } /** * Constructor for intentionally made use cases which * want to control the logging level of output. * * @param level * Level for this filter instance. */ public RecafLoggingFilter(@Nullable Level level) { instanceLevel = () -> Objects.requireNonNullElse(level, Level.TRACE); } @Override public FilterReply decide(@Nonnull ILoggingEvent event) { Level level = event.getLevel(); if (instanceLevel.get().isGreaterOrEqual(level)) return FilterReply.DENY; String loggerName = event.getLoggerName(); if (loggerName.startsWith("software.coley.") || Logging.loggerKeys().contains(loggerName)) return FilterReply.ACCEPT; return FilterReply.DENY; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/behavior/Closing.java ================================================ package software.coley.recaf.behavior; /** * Type is closable. * * @author Matt Coley */ public interface Closing { /** * Called to close the item. */ void close(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/behavior/PriorityKeys.java ================================================ package software.coley.recaf.behavior; /** * Default keys for {@link PrioritySortable} implementations. *

* Searching for usages of these keys will show what listeners/classes fire in which order. * * @author Matt Coley */ public final class PriorityKeys { public static final int EARLIEST = -1000; public static final int EARLIER = -100; public static final int EARLY = -10; public static final int DEFAULT = 0; public static final int LATE = 10; public static final int LATER = 100; public static final int LATEST = 1000; private PriorityKeys() {} } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/behavior/PrioritySortable.java ================================================ package software.coley.recaf.behavior; import jakarta.annotation.Nonnull; import software.coley.collections.Lists; import java.util.List; /** * Priority sortable item. * * @author Matt Coley */ public interface PrioritySortable extends Comparable { /** * @return This item's priority value. * Negative values have higher priority. * Positive values have lower priority. * * @see PriorityKeys */ default int getPriority() { // Everything will default to '0' and the order of items is not guaranteed. // // The idea is that most things do not need a guaranteed run order, but for the few edge cases that do // those specific cases will use higher/lower values to be moved to the front/end of the sorted list. return PriorityKeys.DEFAULT; } @Override default int compareTo(@Nonnull PrioritySortable o) { return Integer.compare(getPriority(), o.getPriority()); } /** * Add a sortable item to a sortable list. * * @param items * List to add to. * @param item * Item to add. * @param * Child priority-sortable type. * * @return {@code true} on add. {@code false} on failure (Insertion index cannot be computed), */ static boolean add(@Nonnull List items, @Nonnull T item) { return Lists.sortedInsert(PrioritySortable::compareTo, items, item); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/cdi/EagerInitialization.java ================================================ package software.coley.recaf.cdi; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Observes; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Applied to beans to enable eager initialization, which is to say they and their dependencies get created as soon as * possible depending on the {@link #value() value of the intended} {@link InitializationStage}. This will result in * the bean's {@code @Inject} annotated constructor being called. *

* Alternatively, you could also observe the events {@link InitializationEvent} or {@link UiInitializationEvent} * in a method with {@link Observes}. This would allow you to separate the initialization logic from the constructor * and have it reside in a separate method. *

* NOTE: Beans are not eagerly initialized while in a test environment. * * @author Matt Coley */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface EagerInitialization { /** * Determines when to run early initialization. *
* Changing this value is mostly applicable to {@link ApplicationScoped} beans. * Having the value set to {@link InitializationStage#IMMEDIATE} will result in the bean and all of * its dependencies being created as soon as the application begins. For beans dealing with UI capabilities this * will likely lead to problems. For those situations, use {@link InitializationStage#AFTER_UI_INIT} to delay * initialization until after the UI has been populated. * * @return When the initialization should occur. */ InitializationStage value() default InitializationStage.IMMEDIATE; } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/cdi/EagerInitializationExtension.java ================================================ package software.coley.recaf.cdi; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Observes; import jakarta.enterprise.inject.spi.Annotated; import jakarta.enterprise.inject.spi.Bean; import jakarta.enterprise.inject.spi.BeanManager; import jakarta.enterprise.inject.spi.Extension; import jakarta.enterprise.inject.spi.ProcessBean; import jakarta.inject.Inject; import java.util.ArrayList; import java.util.List; /** * Extension to force creation of {@link EagerInitialization} annotated beans without the need to * {@link Inject} and reference them externally. * * @author Matt Coley */ public class EagerInitializationExtension implements Extension { private static final EagerInitializationExtension INSTANCE = new EagerInitializationExtension(); private static final List> applicationScopedEagerBeans = new ArrayList<>(); private static final List> applicationScopedEagerBeansForUi = new ArrayList<>(); private static BeanManager beanManager; private EagerInitializationExtension() { } /** * @return Extension singleton. */ @Nonnull public static EagerInitializationExtension getInstance() { return INSTANCE; } /** * @return Application scoped {@link EagerInitialization} beans. */ @Nonnull public static List> getApplicationScopedEagerBeans() { return applicationScopedEagerBeans; } /** * @return Application scoped {@link EagerInitialization} beans which will wait for the UI to be initialized before being initialized. */ @Nonnull public static List> getApplicationScopedEagerBeansForUi() { return applicationScopedEagerBeansForUi; } /** * Called when a bean is discovered and processed. * We will record eager beans here so that we can initialize them later. * * @param event * CDI bean process event. */ public void onProcessBean(@Observes ProcessBean event) { Annotated annotated = event.getAnnotated(); EagerInitialization eager = annotated.getAnnotation(EagerInitialization.class); if (eager != null && annotated.isAnnotationPresent(ApplicationScoped.class)) { if (eager.value() == InitializationStage.IMMEDIATE) applicationScopedEagerBeans.add(event.getBean()); else if (eager.value() == InitializationStage.AFTER_UI_INIT) applicationScopedEagerBeansForUi.add(event.getBean()); } } /** * Called when Recaf initializes the CDI container, and after plugins are loaded. * * @param event * Recaf initialization event. * @param beanManager * CDI bean manager. */ public void onInitialize(@Observes InitializationEvent event, @Nonnull BeanManager beanManager) { EagerInitializationExtension.beanManager = beanManager; for (Bean bean : applicationScopedEagerBeans) create(bean); } /** * Called when the UI is populated. * This obviously means that this only gets called when running from the UI module. * * @param event * UI initialization event. * @param beanManager * CDI bean manager. */ public void onUiInitialize(@Observes UiInitializationEvent event, @Nonnull BeanManager beanManager) { EagerInitializationExtension.beanManager = beanManager; for (Bean bean : applicationScopedEagerBeansForUi) create(bean); } static void create(@Nonnull Bean bean) { // NOTE: Calling toString() triggers the bean's proxy to the real implementation to initialize it. // We have a null check here because under some test environments this may trigger without being set (see above) if (beanManager != null) beanManager.getReference(bean, bean.getBeanClass(), beanManager.createCreationalContext(bean)).toString(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/cdi/InitializationEvent.java ================================================ package software.coley.recaf.cdi; /** * Empty type, used to notify CDI consumers observing this type that the application has been launched. * * @author Matt Coley * @see UiInitializationEvent Alternative which waits for the UI to initialize. */ public class InitializationEvent { } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/cdi/InitializationStage.java ================================================ package software.coley.recaf.cdi; /** * Initialization stage for {@link EagerInitialization#value()}. * * @author Matt Coley * @see EagerInitialization */ public enum InitializationStage { /** * Occurs as soon as possible. */ IMMEDIATE, /** * Occurs after the UI is initialized. */ AFTER_UI_INIT } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/cdi/UiInitializationEvent.java ================================================ package software.coley.recaf.cdi; /** * Empty type, used to notify CDI consumers observing this type that the UI has been populated. * * @author Matt Coley * @see InitializationEvent Alternative which runs before the UI is initialized. */ public class UiInitializationEvent { } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/config/BasicCollectionConfigValue.java ================================================ package software.coley.recaf.config; import jakarta.annotation.Nonnull; import software.coley.observables.ObservableCollection; import java.util.Collection; /** * Basic implementation of {@link ConfigCollectionValue}. * * @param * Value type. * @param * Collection type. * * @author Matt Coley */ public class BasicCollectionConfigValue> implements ConfigCollectionValue { private final String key; private final Class collectionType; private final Class itemType; private final ObservableCollection observable; private final boolean hidden; /** * @param key * Value key. * @param type * Value type class. * @param observable * Observable of value. */ @SuppressWarnings("rawtypes") public BasicCollectionConfigValue(@Nonnull String key, @Nonnull Class type, @Nonnull Class itemType, @Nonnull ObservableCollection observable) { this(key, type, itemType, observable, false); } /** * @param key * Value key. * @param type * Value type class. * @param observable * Observable of value. * @param hidden * Hidden flag. */ @SuppressWarnings({"rawtypes", "unchecked"}) public BasicCollectionConfigValue(@Nonnull String key, @Nonnull Class type, @Nonnull Class itemType, @Nonnull ObservableCollection observable, boolean hidden) { this.key = key; this.collectionType = (Class) type; this.itemType = itemType; this.observable = observable; this.hidden = hidden; } @Nonnull @Override public String getId() { return key; } @Nonnull @Override public Class getType() { return collectionType; } @Override public Class getItemType() { return itemType; } @Nonnull @Override public ObservableCollection getObservable() { return observable; } @Override public boolean isHidden() { return hidden; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; BasicCollectionConfigValue other = (BasicCollectionConfigValue) o; if (!key.equals(other.key)) return false; if (!collectionType.equals(other.collectionType)) return false; if (!itemType.equals(other.itemType)) return false; return observable.equals(other.observable); } @Override public int hashCode() { int result = key.hashCode(); result = 31 * result + collectionType.hashCode(); result = 31 * result + itemType.hashCode(); result = 31 * result + observable.hashCode(); return result; } @Override public String toString() { return "BasicCollectionConfigValue{" + "key='" + key + '\'' + ", collectionType=" + collectionType + ", itemType=" + itemType + '}'; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/config/BasicConfigContainer.java ================================================ package software.coley.recaf.config; import jakarta.annotation.Nonnull; import java.util.Map; import java.util.TreeMap; /** * Basic implementation of {@link ConfigContainer} * * @author Matt Coley */ public class BasicConfigContainer implements ConfigContainer { private final Map> configMap = new TreeMap<>(); private final String group; private final String id; /** * @param group * Container group. * @param id * Container ID. */ public BasicConfigContainer(@Nonnull String group, @Nonnull String id) { this.group = group; this.id = id; } /** * @param value * Value to add. */ protected void addValue(@Nonnull ConfigValue value) { configMap.put(value.getId(), value); } @Nonnull @Override public String getGroup() { return group; } @Nonnull @Override public String getId() { return id; } @Nonnull @Override public Map> getValues() { return configMap; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; BasicConfigContainer that = (BasicConfigContainer) o; if (!configMap.equals(that.configMap)) return false; return id.equals(that.id); } @Override public int hashCode() { int result = configMap.hashCode(); result = 31 * result + id.hashCode(); return result; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/config/BasicConfigValue.java ================================================ package software.coley.recaf.config; import jakarta.annotation.Nonnull; import software.coley.observables.Observable; /** * Basic implementation of {@link ConfigValue}. * * @param * Value type. * * @author Matt Coley */ public class BasicConfigValue implements ConfigValue { private final String key; private final Class type; private final Observable observable; private final boolean hidden; /** * @param key * Value key. * @param type * Value type class. * @param observable * Observable of value. */ public BasicConfigValue(String key, Class type, Observable observable) { this(key, type, observable, false); } /** * @param key * Value key. * @param type * Value type class. * @param observable * Observable of value. * @param hidden * Hidden flag. */ public BasicConfigValue(String key, Class type, Observable observable, boolean hidden) { this.key = key; this.type = type; this.observable = observable; this.hidden = hidden; } @Nonnull @Override public String getId() { return key; } @Nonnull @Override public Class getType() { return type; } @Nonnull @Override public Observable getObservable() { return observable; } @Override public boolean isHidden() { return hidden; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; BasicConfigValue other = (BasicConfigValue) o; if (!key.equals(other.key)) return false; return type.equals(other.type); } @Override public int hashCode() { int result = key.hashCode(); result = 31 * result + type.hashCode(); return result; } @Override public String toString() { return "BasicConfigValue{" + "key='" + key + '\'' + ", type=" + type + '}'; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/config/BasicMapConfigValue.java ================================================ package software.coley.recaf.config; import jakarta.annotation.Nonnull; import software.coley.observables.ObservableMap; import java.util.Map; /** * Basic implementation of {@link ConfigMapValue}. * * @param * Map key type. * @param * Map value type. * @param * Map type. * * @author Matt Coley */ public class BasicMapConfigValue> implements ConfigMapValue { private final String key; private final Class keyType; private final Class valueType; private final Class mapType; private final ObservableMap observable; private final boolean hidden; /** * @param key * Value key. * @param type * Value type class. * @param observable * Observable of value. */ @SuppressWarnings("rawtypes") public BasicMapConfigValue(String key, Class type, Class keyType, Class valueType, ObservableMap observable) { this(key, type, keyType, valueType, observable, false); } /** * @param key * Value key. * @param type * Value type class. * @param observable * Observable of value. * @param hidden * Hidden flag. */ @SuppressWarnings({"rawtypes", "unchecked"}) public BasicMapConfigValue(String key, Class type, Class keyType, Class valueType, ObservableMap observable, boolean hidden) { this.key = key; this.mapType = (Class) type; this.keyType = keyType; this.valueType = valueType; this.observable = observable; this.hidden = hidden; } @Nonnull @Override public String getId() { return key; } @Nonnull @Override public Class getType() { return mapType; } @Override public Class getKeyType() { return keyType; } @Override public Class getValueType() { return valueType; } @Nonnull @Override public ObservableMap getObservable() { return observable; } @Override public boolean isHidden() { return hidden; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; BasicMapConfigValue that = (BasicMapConfigValue) o; if (hidden != that.hidden) return false; if (!key.equals(that.key)) return false; if (!keyType.equals(that.keyType)) return false; if (!valueType.equals(that.valueType)) return false; if (!mapType.equals(that.mapType)) return false; return observable.equals(that.observable); } @Override public int hashCode() { int result = key.hashCode(); result = 31 * result + keyType.hashCode(); result = 31 * result + valueType.hashCode(); result = 31 * result + mapType.hashCode(); result = 31 * result + observable.hashCode(); result = 31 * result + (hidden ? 1 : 0); return result; } @Override public String toString() { return "BasicMapConfigValue{" + "key='" + key + '\'' + ", keyType=" + keyType + ", valueType=" + valueType + ", mapType=" + mapType + ", observable=" + observable + ", hidden=" + hidden + '}'; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/config/ConfigCollectionValue.java ================================================ package software.coley.recaf.config; import java.util.Collection; /** * An option stored in a {@link ConfigContainer} object representing a collection. * * @param * Collection value type. * @param * Collection type. * * @author Matt Coley */ public interface ConfigCollectionValue> extends ConfigValue { /** * @return Collection type. */ Class getItemType(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/config/ConfigContainer.java ================================================ package software.coley.recaf.config; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import java.util.Map; import static software.coley.recaf.config.ConfigGroups.PACKAGE_SPLIT; /** * Configurable object. Implementations will almost always want to use {@link ApplicationScoped} for their scope. * This is so that the config container is shared between all instances of the item it is a config for. * * @author Matt Coley. * @see ConfigValue Values within this container. * @see ConfigGroups Values for {@link #getGroup()}. */ public interface ConfigContainer { String CONFIG_SUFFIX = "-config"; /** * @return Group ID the container belongs to. * * @see ConfigGroups For constant values. */ @Nonnull String getGroup(); /** * The unique ID of this container should be globally unique. * The {@link #getGroup() group} does not act as an identifier prefix. * * @return Unique ID of this container. */ @Nonnull String getId(); /** * @return Combined {@link #getGroup()} and {@link #getId()}. */ @Nonnull default String getGroupAndId() { return getGroup() + PACKAGE_SPLIT + getId(); } /** * @return Values in the container. */ @Nonnull Map> getValues(); /** * @param value * Value to get path of. * * @return Full path, scoped to this container. */ @Nonnull default String getScopedId(ConfigValue value) { return getGroupAndId() + PACKAGE_SPLIT + value.getId(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/config/ConfigGroups.java ================================================ package software.coley.recaf.config; import software.coley.recaf.services.Service; /** * Constants for {@link ConfigContainer#getGroup()}. * * @author Matt Coley */ public final class ConfigGroups { /** * Used to split group into sections. */ public static final String PACKAGE_SPLIT = "."; /** * Group base for {@link Service} classes. */ public static final String SERVICE = "service"; /** * Group for analyzing components. */ public static final String SERVICE_ANALYSIS = SERVICE + PACKAGE_SPLIT + "analysis"; /** * Group for assembler components. */ public static final String SERVICE_ASSEMBLER = SERVICE + PACKAGE_SPLIT + "assembler"; /** * Group for compiler components. */ public static final String SERVICE_COMPILE = SERVICE + PACKAGE_SPLIT + "compile"; /** * Group for debug/attach components. */ public static final String SERVICE_DEBUG = SERVICE + PACKAGE_SPLIT + "debug"; /** * Group for decompilation components. */ public static final String SERVICE_DECOMPILE = SERVICE + PACKAGE_SPLIT + "decompile"; /** * Group for specific decompiler components. */ public static final String SERVICE_DECOMPILE_IMPL = SERVICE_DECOMPILE + PACKAGE_SPLIT + "impl"; /** * Group for IO components. */ public static final String SERVICE_IO = SERVICE + PACKAGE_SPLIT + "io"; /** * Group for mapping components. */ public static final String SERVICE_MAPPING = SERVICE + PACKAGE_SPLIT + "mapping"; /** * Group for plugin components. */ public static final String SERVICE_PLUGIN = SERVICE + PACKAGE_SPLIT + "plugin"; /** * Group for transformation components. */ public static final String SERVICE_TRANSFORM = SERVICE + PACKAGE_SPLIT + "transform"; /** * Group base for UI classes. */ public static final String SERVICE_UI = SERVICE + PACKAGE_SPLIT + "ui"; /** * Group for 3rd party. *
* Plugin registering new {@link ConfigContainer} instances should use this as the {@link ConfigContainer#getGroup()}. * This group is given special treatment in the UI. */ public static final String EXTERNAL = "external"; private ConfigGroups() { } /** * @param container * Container to get packages from. * * @return Group packages. */ public static String[] getGroupPackages(ConfigContainer container) { return container.getGroup().split('\\' + PACKAGE_SPLIT); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/config/ConfigMapValue.java ================================================ package software.coley.recaf.config; import java.util.Map; /** * An option stored in a {@link ConfigContainer} object representing a map. * * @param * Map key type. * @param * Map value type. * @param * Map type. * * @author Matt Coley */ public interface ConfigMapValue> extends ConfigValue { /** * @return Map key type. */ Class getKeyType(); /** * @return Map value type. */ Class getValueType(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/config/ConfigPersistence.java ================================================ package software.coley.recaf.config; import jakarta.annotation.Nonnull; /** * Outline of persistence for {@link ConfigContainer} * * @author Matt Coley */ public interface ConfigPersistence { /** * @param container * Container with values to save to persistent medium. */ void save(@Nonnull ConfigContainer container); /** * @param container * Container with values to load from persistent medium. */ void load(@Nonnull ConfigContainer container); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/config/ConfigValue.java ================================================ package software.coley.recaf.config; import jakarta.annotation.Nonnull; import software.coley.observables.Observable; /** * An option stored in a {@link ConfigContainer} object. * * @param * Value type. * * @author Matt Coley */ public interface ConfigValue { /** * @return Unique ID of this value. */ @Nonnull String getId(); /** * @return Value type class. */ @Nonnull Class getType(); /** * @return Observable of value. */ @Nonnull Observable getObservable(); /** * @param value * Value to set. */ default void setValue(@Nonnull T value) { getObservable().setValue(value); } /** * @return Current value. */ @Nonnull default T getValue() { return getObservable().getValue(); } /** * @return {@code true} for hidden values not to be shown to users (Strictly in the UI). */ default boolean isHidden() { return false; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/config/RestoreAwareConfigContainer.java ================================================ package software.coley.recaf.config; import software.coley.recaf.services.config.ConfigManager; /** * Outline of a config container that is aware of when its contents are restored. * * @author Matt Coley */ public interface RestoreAwareConfigContainer extends ConfigContainer { /** * Called when this container has its contents restored via {@link ConfigManager}. */ default void onRestore() {} /** * Called when this container was checked for contents via {@link ConfigManager} for restoration, * but no local files were found and thus there was nothing to restore. */ default void onNoRestore() {} } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/Accessed.java ================================================ package software.coley.recaf.info; import software.coley.recaf.info.member.ClassMember; import static java.lang.reflect.Modifier.*; /** * Outline of a class or member with access modifiers. * * @author Matt Coley * @see ClassMember * @see ClassInfo */ public interface Accessed { /** * @return Access modifiers. */ int getAccess(); /** * @return {@code true} when this item's access modifiers contains {@code public}. */ default boolean hasPublicModifier() { return hasModifierMask(PUBLIC); } /** * @return {@code true} when this item's access modifiers contains {@code protected}. */ default boolean hasProtectedModifier() { return hasModifierMask(PROTECTED); } /** * @return {@code true} when this item's access modifiers contains {@code private}. */ default boolean hasPrivateModifier() { return hasModifierMask(PRIVATE); } /** * @return {@code true} when this item's access modifiers contains none of: * {@code public}, {@code protected}, or {@code private}. */ default boolean hasPackagePrivateModifier() { return hasNoneOfMask(PRIVATE | PROTECTED | PUBLIC); } /** * @return {@code true} when this item's access modifiers contains {@code static}. */ default boolean hasStaticModifier() { return hasModifierMask(STATIC); } /** * @return {@code true} when this item's access modifiers contains {@code final}. */ default boolean hasFinalModifier() { return hasModifierMask(FINAL); } /** * @return {@code true} when this item's access modifiers contains {@code synchronized}. */ default boolean hasSynchronizedModifier() { return hasModifierMask(SYNCHRONIZED); } /** * @return {@code true} when this item's access modifiers contains {@code volatile}. */ default boolean hasVolatileModifier() { return hasModifierMask(VOLATILE); } /** * @return {@code true} when this item's access modifiers contains {@code transient}. */ default boolean hasTransientModifier() { return hasModifierMask(TRANSIENT); } /** * @return {@code true} when this item's access modifiers contains {@code native}. */ default boolean hasNativeModifier() { return hasModifierMask(NATIVE); } /** * @return {@code true} when this item's access modifiers contains {@code enum}. */ default boolean hasEnumModifier() { return hasModifierMask(0x00004000); } /** * @return {@code true} when this item's access modifiers contains {@code annotation}. */ default boolean hasAnnotationModifier() { return hasModifierMask(0x00002000); } /** * @return {@code true} when this item's access modifiers contains {@code interface}. */ default boolean hasInterfaceModifier() { return hasModifierMask(INTERFACE); } /** * @return {@code true} when this item's access modifiers contains {@code module}. */ default boolean hasModuleModifier() { return hasModifierMask(0x8000); } /** * @return {@code true} when this item's access modifiers contains {@code abstract}. */ default boolean hasAbstractModifier() { return hasModifierMask(ABSTRACT); } /** * @return {@code true} when this item's access modifiers contains {@code strictfp}. */ default boolean hasStrictFpModifier() { return hasModifierMask(STRICT); } /** * @return {@code true} when this item's access modifiers contains {@code varargs}. */ default boolean hasVarargsModifier() { return hasModifierMask(0x00000080); } /** * @return {@code true} when this item's access modifiers contains {@code bridge}. */ default boolean hasBridgeModifier() { return hasModifierMask(0x00000040); } /** * @return {@code true} when this item's access modifiers contains {@code synthetic}. */ default boolean hasSyntheticModifier() { return hasModifierMask(0x00001000); } /** * @return {@code true} when {@link #hasSyntheticModifier()} or {@link #hasBridgeModifier()} are {@code true}. */ default boolean isCompilerGenerated() { return hasBridgeModifier() || hasSyntheticModifier(); } /** * @param mask * Mask to check. * * @return {@code true} if the access modifiers of this item match the given mask. */ default boolean hasModifierMask(int mask) { return (getAccess() & mask) == mask; } /** * @param modifiers * Modifiers to check. * * @return {@code true} if the access modifiers of this item contain all the given modifiers. */ default boolean hasAllModifiers(int... modifiers) { for (int modifier : modifiers) if (!hasModifierMask(modifier)) return false; return true; } /** * @param modifiers * Modifiers to check. * * @return {@code true} if the access modifiers of this item contain any the given modifiers. */ default boolean hasAnyModifiers(int... modifiers) { for (int modifier : modifiers) if (hasModifierMask(modifier)) return true; return false; } /** * @param mask * Mask to check. * * @return {@code true} if the access modifiers of this item do not match the mask. */ default boolean hasNoneOfMask(int mask) { return (getAccess() & mask) == 0; } /** * @param modifiers * Modifiers to check. * * @return {@code true} if the access modifiers of this item contain none the given modifiers. */ default boolean hasNoneOfModifiers(int... modifiers) { for (int modifier : modifiers) if (!hasNoneOfMask(modifier)) return false; return true; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/AndroidChunkFileInfo.java ================================================ package software.coley.recaf.info; import com.google.devrel.gmscore.tools.apk.arsc.BinaryResourceFile; import jakarta.annotation.Nonnull; /** * Outline of files that utilize the Android chunk format. * * @author Matt Coley * @see BinaryXmlFileInfo For {@code AndroidManifest.xml} contents. * @see ArscFileInfo For {@code resources.arsc} contents. */ public interface AndroidChunkFileInfo extends FileInfo { /** * @return Model representation of the chunk file. */ @Nonnull BinaryResourceFile getChunkModel(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/AndroidClassInfo.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; import software.coley.recaf.info.builder.AndroidClassInfoBuilder; import java.util.function.Consumer; import java.util.function.Predicate; /** * Outline of an Android class. * * @author Matt Coley */ public interface AndroidClassInfo extends ClassInfo { /** * @return {@code true} when {@link #asJvmClass()} can act as a mapping operation. * {@code false} when mapping to JVM classes is unsupported. */ default boolean canMapToJvmClass() { return false; } /** * @return New builder wrapping this class information. */ @Nonnull default AndroidClassInfoBuilder toAndroidBuilder() { return new AndroidClassInfoBuilder(this); } @Override default void acceptIfJvmClass(@Nonnull Consumer action) { // no-op } @Override default void acceptIfAndroidClass(@Nonnull Consumer action) { action.accept(this); } @Override default boolean testIfJvmClass(@Nonnull Predicate predicate) { return false; } @Override default boolean testIfAndroidClass(@Nonnull Predicate predicate) { return predicate.test(this); } @Nonnull @Override default JvmClassInfo asJvmClass() { throw new IllegalStateException("Android class cannot be cast to JVM class"); } @Nonnull @Override default AndroidClassInfo asAndroidClass() { return this; } @Override default boolean isJvmClass() { return false; } @Override default boolean isAndroidClass() { return true; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/ApkFileInfo.java ================================================ package software.coley.recaf.info; /** * Outline of an APK Android file container. * * @author Matt Coley */ public interface ApkFileInfo extends ZipFileInfo { } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/ArscFileInfo.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; import software.coley.android.xml.AndroidResourceProvider; /** * Outline of a ARSC file, used by Android APK's. * * @author Matt Coley */ public interface ArscFileInfo extends AndroidChunkFileInfo { /** * Standard name of ARSC resource file in APK files. */ String NAME = "resources.arsc"; /** * @return Resource information extracted from the ARSC file contents. */ @Nonnull AndroidResourceProvider getResourceInfo(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/AudioFileInfo.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; /** * Outline of an audio file. * * @author Matt Coley */ public interface AudioFileInfo extends FileInfo { @Nonnull @Override default AudioFileInfo asAudioFile() { return this; } @Override default boolean isAudioFile() { return true; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/BasicAndroidChunkFileInfo.java ================================================ package software.coley.recaf.info; import com.google.devrel.gmscore.tools.apk.arsc.BinaryResourceFile; import jakarta.annotation.Nonnull; import software.coley.recaf.info.builder.ChunkFileInfoBuilder; /** * Common implementation for {@link BasicBinaryXmlFileInfo} and {@link BasicArscFileInfo}. * * @author Matt Coley */ public class BasicAndroidChunkFileInfo extends BasicFileInfo implements AndroidChunkFileInfo { private BinaryResourceFile resourceFile; /** * @param builder * Builder to pull information from. */ public BasicAndroidChunkFileInfo(ChunkFileInfoBuilder builder) { super(builder); } @Nonnull @Override public BinaryResourceFile getChunkModel() { if (resourceFile == null) resourceFile = new BinaryResourceFile(getRawContent()); return resourceFile; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/BasicAndroidClassInfo.java ================================================ package software.coley.recaf.info; import com.android.tools.r8.graph.DexProgramClass; import jakarta.annotation.Nonnull; import org.objectweb.asm.ClassReader; import software.coley.dextranslator.Options; import software.coley.dextranslator.ir.ConversionException; import software.coley.dextranslator.model.ApplicationData; import software.coley.recaf.info.builder.AndroidClassInfoBuilder; import software.coley.recaf.info.builder.JvmClassInfoBuilder; import java.io.IOException; import java.util.Collections; /** * Basic Android class info implementation. * * @author Matt Coley */ public class BasicAndroidClassInfo extends BasicClassInfo implements AndroidClassInfo { private final DexProgramClass dexClass; private JvmClassInfo converted; /** * @param builder * Builder to pull info from. */ public BasicAndroidClassInfo(@Nonnull AndroidClassInfoBuilder builder) { super(builder); dexClass = builder.getDexClass(); } @Override public boolean canMapToJvmClass() { // See below return true; } /** * @return Translation into JVM class. */ @Nonnull @Override public JvmClassInfo asJvmClass() { if (converted == null) { // This is an expensive operation, so it's best to make other thread access wait until it is done and // then use the singular return value. synchronized (this) { // If there are 2+ requests here, and we let one through to completion, when the others are let in // this value should be computed then. if (converted != null) return converted; try { String name = getName(); ApplicationData data = ApplicationData.fromProgramClasses(Collections.singleton(dexClass)); data.setOperationOptionsProvider(() -> new Options() .enableLoadStoreOptimization() .setLenient(true) .setReplaceInvalidMethodBodies(true)); byte[] convertedBytecode = data.exportToJvmClass(name); if (convertedBytecode == null) throw new IllegalStateException("Failed to convert Dalvik model of " + name + " to JVM bytecode, " + "conversion results did not include type name."); ClassReader reader = new ClassReader(convertedBytecode); converted = new JvmClassInfoBuilder(reader).build(); } catch (ConversionException | IOException ex) { throw new IllegalStateException(ex); } } } return converted; } /** * @return Backing program class node. */ @Nonnull public DexProgramClass getDexClass() { return dexClass; } @Override public String toString() { return "Android class: " + getName(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/BasicApkFileInfo.java ================================================ package software.coley.recaf.info; import software.coley.recaf.info.builder.ZipFileInfoBuilder; /** * Basic implementation of an Android APK file info. * * @author Matt Coley */ public class BasicApkFileInfo extends BasicZipFileInfo implements ApkFileInfo { /** * @param builder * Builder to pull information from. */ public BasicApkFileInfo(ZipFileInfoBuilder builder) { super(builder); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/BasicArscFileInfo.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; import org.slf4j.Logger; import software.coley.android.xml.AndroidResourceProvider; import software.coley.android.xml.NoopAndroidResourceProvider; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.info.builder.ArscFileInfoBuilder; import software.coley.recaf.util.android.AndroidRes; /** * Basic implementation of ARSC file info. * * @author Matt Coley */ public class BasicArscFileInfo extends BasicAndroidChunkFileInfo implements ArscFileInfo { private static final Logger logger = Logging.get(BasicArscFileInfo.class); private AndroidResourceProvider res; /** * @param builder * Builder to pull information from. */ public BasicArscFileInfo(ArscFileInfoBuilder builder) { super(builder); } @Nonnull @Override public AndroidResourceProvider getResourceInfo() { if (res == null) try { res = AndroidRes.fromArsc(getChunkModel()); } catch (Throwable t) { logger.error("Failed to decode '{}', will use an empty model instead", getName(), t); res = NoopAndroidResourceProvider.INSTANCE; } return res; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/BasicAudioFileInfo.java ================================================ package software.coley.recaf.info; import software.coley.recaf.info.builder.AudioFileInfoBuilder; /** * Basic implementation of an audio file info. * * @author Matt Coley */ public class BasicAudioFileInfo extends BasicFileInfo implements AudioFileInfo { /** * @param builder * Builder to pull information from. */ public BasicAudioFileInfo(AudioFileInfoBuilder builder) { super(builder); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/BasicBinaryXmlFileInfo.java ================================================ package software.coley.recaf.info; import software.coley.recaf.info.builder.BinaryXmlFileInfoBuilder; /** * Basic implementation of binary XML file info. * * @author Matt Coley */ public class BasicBinaryXmlFileInfo extends BasicAndroidChunkFileInfo implements BinaryXmlFileInfo { /** * @param builder * Builder to pull information from. */ public BasicBinaryXmlFileInfo(BinaryXmlFileInfoBuilder builder) { super(builder); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/BasicClassInfo.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; import software.coley.recaf.info.annotation.AnnotationInfo; import software.coley.recaf.info.annotation.TypeAnnotationInfo; import software.coley.recaf.info.builder.AbstractClassInfoBuilder; import software.coley.recaf.info.member.BasicMember; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.info.properties.Property; import software.coley.recaf.info.properties.PropertyContainer; import software.coley.recaf.util.Types; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.stream.Stream; /** * Basic implementation of class info. * * @author Matt Coley * @see BasicJvmClassInfo * @see BasicAndroidClassInfo */ public abstract class BasicClassInfo implements ClassInfo { private static final int SIGS_VALID = 1; private static final int SIGS_INVALID = 0; private static final int SIGS_UNKNOWN = -1; private final PropertyContainer properties; private final String name; private final String superName; private final List interfaces; private final int access; private final String signature; private final String sourceFileName; private final List annotations; private final List typeAnnotations; private final String outerClassName; private final String outerMethodName; private final String outerMethodDescriptor; private final List innerClasses; private final List fields; private final List methods; private List breadcrumbs; private int sigCheck = SIGS_UNKNOWN; protected BasicClassInfo(@Nonnull AbstractClassInfoBuilder builder) { this(builder.getName(), builder.getSuperName(), builder.getInterfaces(), builder.getAccess(), builder.getSignature(), builder.getSourceFileName(), builder.getAnnotations(), builder.getTypeAnnotations(), builder.getOuterClassName(), builder.getOuterMethodName(), builder.getOuterMethodDescriptor(), builder.getInnerClasses(), builder.getFields(), builder.getMethods(), builder.getPropertyContainer()); } protected BasicClassInfo(@Nonnull String name, String superName, @Nonnull List interfaces, int access, String signature, String sourceFileName, @Nonnull List annotations, @Nonnull List typeAnnotations, String outerClassName, String outerMethodName, String outerMethodDescriptor, @Nonnull List innerClasses, @Nonnull List fields, @Nonnull List methods, @Nonnull PropertyContainer properties) { this.name = name; this.superName = superName; this.interfaces = interfaces; this.access = access; this.signature = signature; this.sourceFileName = sourceFileName; this.annotations = annotations; this.typeAnnotations = typeAnnotations; this.outerClassName = outerClassName; this.outerMethodName = outerMethodName; this.outerMethodDescriptor = outerMethodDescriptor; this.innerClasses = innerClasses; this.fields = fields; this.methods = methods; this.properties = properties; // Link fields/methods to self Stream.concat(fields.stream(), methods.stream()) .filter(member -> member instanceof BasicMember) .map(member -> (BasicMember) member) .forEach(member -> member.setDeclaringClass(this)); } @Nonnull @Override public String getName() { return name; } @Override public String getSuperName() { return superName; } @Nonnull @Override public List getInterfaces() { return interfaces; } @Override public int getAccess() { return access; } @Override public String getSignature() { return signature; } @Override public String getSourceFileName() { return sourceFileName; } @Nonnull @Override public List getAnnotations() { return annotations; } @Nonnull @Override public List getTypeAnnotations() { return typeAnnotations; } @Override public String getOuterClassName() { return outerClassName; } @Override public String getOuterMethodName() { return outerMethodName; } @Override public String getOuterMethodDescriptor() { return outerMethodDescriptor; } @Nonnull @Override public List getOuterClassBreadcrumbs() { if (breadcrumbs == null) { String currentOuter = getOuterClassName(); if (currentOuter == null) return breadcrumbs = Collections.emptyList(); int maxOuterDepth = 10; List list = new ArrayList<>(); int counter = 0; while (currentOuter != null) { if (++counter > maxOuterDepth) { list.clear(); // assuming some obfuscator is at work, so breadcrumbs might be invalid. break; } list.addFirst(currentOuter); String targetOuter = currentOuter; currentOuter = innerClasses.stream() .filter(i -> i.getInnerClassName().equals(targetOuter) && i.getOuterClassName() != null) .map(InnerClassInfo::getOuterClassName) .findFirst().orElse(null); } breadcrumbs = Collections.unmodifiableList(list); } return breadcrumbs; } @Nonnull @Override public List getInnerClasses() { return innerClasses; } @Nonnull @Override public List getFields() { return fields; } @Nonnull @Override public List getMethods() { return methods; } @Override public void setProperty(Property property) { properties.setProperty(property); } @Override public void removeProperty(String key) { properties.removeProperty(key); } @Nonnull @Override public Map> getProperties() { return properties.getProperties(); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null) return false; if (o instanceof ClassInfo other) { // NOTE: Do NOT consider the properties since contents of the map can point back to this instance // or our containing resource, causing a cycle. if (access != other.getAccess()) return false; if (!name.equals(other.getName())) return false; if (!Objects.equals(superName, other.getSuperName())) return false; if (!interfaces.equals(other.getInterfaces())) return false; if (!Objects.equals(signature, other.getSignature())) return false; if (!Objects.equals(sourceFileName, other.getSourceFileName())) return false; if (!annotations.equals(other.getAnnotations())) return false; if (!typeAnnotations.equals(other.getTypeAnnotations())) return false; if (!Objects.equals(outerClassName, other.getOuterClassName())) return false; if (!Objects.equals(outerMethodName, other.getOuterMethodName())) return false; if (!Objects.equals(outerMethodDescriptor, other.getOuterMethodDescriptor())) return false; if (!innerClasses.equals(other.getInnerClasses())) return false; if (!fields.equals(other.getFields())) return false; return methods.equals(other.getMethods()); } return false; } @Override public int hashCode() { // NOTE: Do NOT consider the properties since contents of the map can point back to this instance // or our containing resource, causing a cycle. int result = name.hashCode(); result = 31 * result + (superName != null ? superName.hashCode() : 0); result = 31 * result + interfaces.hashCode(); result = 31 * result + access; result = 31 * result + (signature != null ? signature.hashCode() : 0); result = 31 * result + (sourceFileName != null ? sourceFileName.hashCode() : 0); result = 31 * result + annotations.hashCode(); result = 31 * result + typeAnnotations.hashCode(); result = 31 * result + (outerClassName != null ? outerClassName.hashCode() : 0); result = 31 * result + (outerMethodName != null ? outerMethodName.hashCode() : 0); result = 31 * result + (outerMethodDescriptor != null ? outerMethodDescriptor.hashCode() : 0); result = 31 * result + innerClasses.hashCode(); result = 31 * result + fields.hashCode(); result = 31 * result + methods.hashCode(); return result; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/BasicDexFileInfo.java ================================================ package software.coley.recaf.info; import software.coley.recaf.info.builder.DexFileInfoBuilder; /** * Basic implementation of an Android DEX file info. * * @author Matt Coley */ public class BasicDexFileInfo extends BasicFileInfo implements DexFileInfo { /** * @param builder * Builder to pull information from. */ public BasicDexFileInfo(DexFileInfoBuilder builder) { super(builder); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/BasicFileInfo.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; import software.coley.recaf.info.builder.FileInfoBuilder; import software.coley.recaf.info.properties.Property; import software.coley.recaf.info.properties.PropertyContainer; import java.util.Arrays; import java.util.Map; /** * Basic implementation of file info. * * @author Matt Coley */ public class BasicFileInfo implements FileInfo { private final PropertyContainer properties; private final String name; private final byte[] rawContent; public BasicFileInfo(@Nonnull FileInfoBuilder builder) { this(builder.getName(), builder.getRawContent(), builder.getProperties()); } /** * @param name * File name/path. * @param rawContent * Raw contents of file. * @param properties * Assorted properties. */ public BasicFileInfo(@Nonnull String name, @Nonnull byte[] rawContent, @Nonnull PropertyContainer properties) { this.name = name; this.rawContent = rawContent; this.properties = properties; } @Nonnull @Override public byte[] getRawContent() { return rawContent; } @Nonnull @Override public String getName() { return name; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null) return false; if (o instanceof FileInfo other) { if (!name.equals(other.getName())) return false; return Arrays.equals(rawContent, other.getRawContent()); } return false; } @Override public int hashCode() { int result = name.hashCode(); result = 31 * result + Arrays.hashCode(rawContent); return result; } @Override public void setProperty(Property property) { properties.setProperty(property); } @Override public void removeProperty(String key) { properties.removeProperty(key); } @Nonnull @Override public Map> getProperties() { return properties.getProperties(); } @Override public String toString() { return name; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/BasicImageFileInfo.java ================================================ package software.coley.recaf.info; import software.coley.recaf.info.builder.ImageFileInfoBuilder; /** * Basic implementation of an image file info. * * @author Matt Coley */ public class BasicImageFileInfo extends BasicFileInfo implements ImageFileInfo { /** * @param builder * Builder to pull information from. */ public BasicImageFileInfo(ImageFileInfoBuilder builder) { super(builder); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/BasicInnerClassInfo.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; import java.util.Objects; /** * Basic implementation of inner class info. * * @author Matt Coley */ public class BasicInnerClassInfo implements InnerClassInfo { private final String outerDeclaringClassName; // Recaf specific, not modeling class spec private final String innerClassName; private final String outerClassName; private final String innerName; private final int access; private String simpleName; /** * @param outerDeclaringClassName * Declaring class name, * @param innerClassName * Inner name. * @param outerClassName * Outer name. * @param innerName * Local inner name. * @param access * Inner class flags originally declared. */ public BasicInnerClassInfo(String outerDeclaringClassName, String innerClassName, String outerClassName, String innerName, int access) { this.outerDeclaringClassName = outerDeclaringClassName; this.innerClassName = innerClassName; this.outerClassName = outerClassName; this.innerName = innerName; this.access = access; } @Override public int getAccess() { return access; } @Nonnull @Override public String getOuterDeclaringClassName() { return outerDeclaringClassName; } @Nonnull @Override public String getInnerClassName() { return innerClassName; } @Override public String getOuterClassName() { return outerClassName; } @Override public String getInnerName() { return innerName; } @Nonnull @Override public String getSimpleName() { // Cache simple name computation if (simpleName == null) simpleName = InnerClassInfo.super.getSimpleName(); return simpleName; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null) return false; if (o instanceof InnerClassInfo inner) { if (access != inner.getAccess()) return false; if (!innerClassName.equals(inner.getInnerClassName())) return false; if (!Objects.equals(outerClassName, inner.getOuterClassName())) return false; return Objects.equals(innerName, inner.getInnerName()); } return false; } @Override public int hashCode() { int result = innerClassName.hashCode(); result = 31 * result + (outerClassName != null ? outerClassName.hashCode() : 0); result = 31 * result + (innerName != null ? innerName.hashCode() : 0); result = 31 * result + access; return result; } @Override public String toString() { return "Inner class: " + getSimpleName(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/BasicJModFileInfo.java ================================================ package software.coley.recaf.info; import software.coley.recaf.info.builder.JModFileInfoBuilder; /** * Basic implementation of JMod file info. * * @author Matt Coley */ public class BasicJModFileInfo extends BasicZipFileInfo implements JModFileInfo { /** * @param builder * Builder to pull information from. */ public BasicJModFileInfo(JModFileInfoBuilder builder) { super(builder); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/BasicJarFileInfo.java ================================================ package software.coley.recaf.info; import software.coley.recaf.info.builder.JarFileInfoBuilder; /** * Basic implementation of JAR file info. * * @author Matt Coley */ public class BasicJarFileInfo extends BasicZipFileInfo implements JarFileInfo { /** * @param builder * Builder to pull information from. */ public BasicJarFileInfo(JarFileInfoBuilder builder) { super(builder); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/BasicJvmClassInfo.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; import org.objectweb.asm.ClassReader; import software.coley.recaf.info.builder.JvmClassInfoBuilder; import java.util.Arrays; /** * Basic JVM class info implementation. * * @author Matt Coley */ public class BasicJvmClassInfo extends BasicClassInfo implements JvmClassInfo { private final byte[] bytecode; private final int version; private ClassReader reader; /** * @param builder * Builder to pull info from. */ public BasicJvmClassInfo(@Nonnull JvmClassInfoBuilder builder) { super(builder); this.bytecode = builder.getBytecode(); this.version = builder.getVersion(); } @Nonnull @Override public byte[] getBytecode() { return bytecode; } @Nonnull @Override public ClassReader getClassReader() { if (reader == null) reader = new ClassReader(bytecode); return reader; } @Override public int getVersion() { return version; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null) return false; if (o instanceof JvmClassInfo other) { if (version != other.getVersion()) return false; return Arrays.equals(bytecode, other.getBytecode()); } else if (!super.equals(o)) { return false; } return false; } @Override public int hashCode() { int result = super.hashCode(); result = 31 * result + Arrays.hashCode(bytecode); result = 31 * result + version; return result; } @Override public String toString() { return "JVM class: " + getName(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/BasicModulesFileInfo.java ================================================ package software.coley.recaf.info; import software.coley.recaf.info.builder.ModulesFileInfoBuilder; /** * Basic implementation of Modules file info. * * @author Matt Coley */ public class BasicModulesFileInfo extends BasicFileInfo implements ModulesFileInfo { /** * @param builder * Builder to pull information from. */ public BasicModulesFileInfo(ModulesFileInfoBuilder builder) { super(builder); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/BasicNativeLibraryFileInfo.java ================================================ package software.coley.recaf.info; import software.coley.recaf.info.builder.NativeLibraryFileInfoBuilder; /** * Basic implementation of a native-library file info. * * @author Matt Coley */ public class BasicNativeLibraryFileInfo extends BasicFileInfo implements NativeLibraryFileInfo { /** * @param builder * Builder to pull information from. */ public BasicNativeLibraryFileInfo(NativeLibraryFileInfoBuilder builder) { super(builder); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/BasicTextFileInfo.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; import software.coley.recaf.info.builder.TextFileInfoBuilder; import java.nio.charset.Charset; /** * Basic implementation of text file info. * * @author Matt Coley */ public class BasicTextFileInfo extends BasicFileInfo implements TextFileInfo { private final Charset charset; private final String text; private String[] lines; /** * @param builder * Builder to pull information from. */ public BasicTextFileInfo(@Nonnull TextFileInfoBuilder builder) { super(builder); text = builder.getText(); charset = builder.getCharset(); } @Nonnull @Override public String getText() { return text; } @Nonnull @Override public String[] getTextLines() { if (lines == null) lines = getText().lines().toArray(String[]::new); return lines; } @Nonnull @Override public Charset getCharset() { return charset; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/BasicVideoFileInfo.java ================================================ package software.coley.recaf.info; import software.coley.recaf.info.builder.VideoFileInfoBuilder; /** * Basic implementation of a video file info. * * @author Matt Coley */ public class BasicVideoFileInfo extends BasicFileInfo implements VideoFileInfo { /** * @param builder * Builder to pull information from. */ public BasicVideoFileInfo(VideoFileInfoBuilder builder) { super(builder); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/BasicWarFileInfo.java ================================================ package software.coley.recaf.info; import software.coley.recaf.info.builder.WarFileInfoBuilder; /** * Basic implementation of WAR file info. * * @author Matt Coley */ public class BasicWarFileInfo extends BasicZipFileInfo implements WarFileInfo { /** * @param builder * Builder to pull information from. */ public BasicWarFileInfo(WarFileInfoBuilder builder) { super(builder); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/BasicZipFileInfo.java ================================================ package software.coley.recaf.info; import software.coley.recaf.info.builder.ZipFileInfoBuilder; /** * Basic implementation of ZIP file info. * * @author Matt Coley */ public class BasicZipFileInfo extends BasicFileInfo implements ZipFileInfo { /** * @param builder * Builder to pull information from. */ public BasicZipFileInfo(ZipFileInfoBuilder builder) { super(builder); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/BinaryXmlFileInfo.java ================================================ package software.coley.recaf.info; /** * Outline of a binary XML file, used by Android APK's. * * @author Matt Coley */ public interface BinaryXmlFileInfo extends AndroidChunkFileInfo { } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/ClassInfo.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.annotation.Annotated; import software.coley.recaf.info.member.ClassMember; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.util.Types; import software.coley.recaf.util.visitors.IllegalSignatureRemovingVisitor; import java.util.List; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Stream; /** * Outline of a class. * * @author Matt Coley * @see JvmClassInfo For JVM classes. * @see AndroidClassInfo For Android classes. */ public interface ClassInfo extends Info, Annotated, Accessed { /** * @return Name of the source file the class was compiled from. * May be {@code null} when there is no debug data attached to the class. */ @Nullable String getSourceFileName(); /** * @return List of implemented interfaces. */ @Nonnull List getInterfaces(); /** * @return Super-name of the class. * May be {@code null} for {@link java.lang.annotation.Annotation} and {@code module-info} classes. */ @Nullable String getSuperName(); /** * @return Package the class resides in. * May be {@code null} for classes in the default package. */ @Nullable default String getPackageName() { String className = getName(); int packageIndex = className.lastIndexOf('/'); if (packageIndex <= 0) return null; return className.substring(0, packageIndex); } /** * @return {@code true} when the class name has no package. */ default boolean isInDefaultPackage() { return getPackageName() == null; } /** * @return Stream of all parent types, where the {@link #getSuperName()} is first if present, * followed by any {@link #getInterfaces()}. */ @Nonnull default Stream parentTypesStream() { return Stream.concat( Stream.ofNullable(getSuperName()), getInterfaces().stream() ); } /** * @return Signature containing generic information. May be {@code null}. */ @Nullable String getSignature(); /** * @return Name of outer class that this is declared in, if this is an inner class. * {@code null} when this class is not an inner class. */ @Nullable String getOuterClassName(); /** * @return Name of the outer method that this is declared in, as an anonymous inner class. * {@code null} when this class is not an inner anonymous class. * * @see #getOuterMethodDescriptor() Descriptor of outer method */ @Nullable String getOuterMethodName(); /** * @return Descriptor of the outer method that this is declared in, as an anonymous inner class. * {@code null} when this class is not an inner anonymous class. * * @see #getOuterMethodName() Name of outer method. */ @Nullable String getOuterMethodDescriptor(); /** * Breadcrumbs of the outer class. * This List MUST be sorted in order of the outermost first. * The last element is the outer of the class itself. *
* For an example, if our class is 'C' then this list will be {@code [foo/A, foo/A$B]}: *

{@code
	 * package foo;
	 *
	 * class A {
	 *     class B {
	 *         class C {}  // This class
	 *     }
	 * }
	 * }
* * @return Breadcrumbs of the outer class. */ @Nonnull List getOuterClassBreadcrumbs(); /** * @return List of declared inner classes. */ @Nonnull List getInnerClasses(); /** * Gets a named inner class by the local name. * Given the following example, you would pass {@code "Bar"} or even {@code "FizzBuzz"}: *
{@code
	 * class Foo {
	 *     class Bar {
	 *         class FizzBuzz {}
	 *     }
	 * }
	 * }
* Because anonymous inner classes do not have a name declared, they cannot be yielded here. * * @param innerName * Local name of inner class. * * @return Inner class of the matching name, or {@code null} if no such inner exists. */ @Nullable default InnerClassInfo getInnerClassByInnerName(@Nonnull String innerName) { for (InnerClassInfo innerClass : getInnerClasses()) if (innerName.equals(innerClass.getInnerName())) return innerClass; return null; } /** * @return {@code true} when this class is an inner class of another class. */ default boolean isInnerClass() { return getOuterClassName() != null || getOuterMethodName() != null; } /** * @param className * Name of a supposed outer class. * * @return {@code true} if this class is an inner class of the given outer class. */ default boolean isInnerClassOf(@Nonnull String className) { // If we don't start with that class name, we can't possibly be an inner class. if (!getName().startsWith(className + "$")) return false; return getOuterClassBreadcrumbs().contains(className); } /** * @return {@code true} when this class is an anonymous inner class of another class. */ default boolean isAnonymousInnerClass() { // Check if the 'full' name of the inner 'InnerClassName' is the current class (entry representing ourselves) // Then if the 'OuterClassName' is null, this means our class does not expose a name because it is anonymous. return getInnerClasses().stream() .anyMatch(inner -> inner.getInnerClassName().equals(getName()) && inner.getOuterClassName() == null); } /** * @return List of declared fields. */ @Nonnull List getFields(); /** * @return List of declared methods. */ @Nonnull List getMethods(); /** * @return Stream of declared fields. */ @Nonnull default Stream fieldStream() { return Stream.of(this).flatMap(self -> self.getFields().stream()); } /** * @return Stream of declared methods. */ @Nonnull default Stream methodStream() { return Stream.of(this).flatMap(self -> self.getMethods().stream()); } /** * @return Stream of declared fields and methods. */ @Nonnull default Stream fieldAndMethodStream() { return Stream.concat(fieldStream(), methodStream()); } /** * Do note that there can be multiple fields with one name if there are different descriptors for each. * To differentiate properly, please use {@link #getDeclaredField(String, String)}. * * @param name * Field name. * * @return First matching field definition, or {@code null} if none were found. */ @Nullable default FieldMember getFirstDeclaredFieldByName(@Nonnull String name) { return fieldStream() .filter(f -> f.getName().equals(name)) .findFirst().orElse(null); } /** * @param name * Field name. * @param descriptor * Field descriptor. * * @return Field matching definition, or {@code null} if none were found. */ @Nullable default FieldMember getDeclaredField(@Nonnull String name, @Nonnull String descriptor) { return fieldStream() .filter(f -> f.getName().equals(name) && f.getDescriptor().equals(descriptor)) .findFirst().orElse(null); } /** * @param name * Method name. * @param descriptor * Method descriptor. * * @return Method matching definition, or {@code null} if none were found. */ @Nullable default MethodMember getDeclaredMethod(@Nonnull String name, @Nonnull String descriptor) { return methodStream() .filter(m -> m.getName().equals(name) && m.getDescriptor().equals(descriptor)) .findFirst().orElse(null); } /** * Do note that there can be multiple methods with one name if there are different method descriptors for each. * To differentiate properly, please use {@link #getDeclaredMethod(String, String)}. * * @param name * Method name. * * @return First matching method definition, or {@code null} if none were found. */ @Nullable default MethodMember getFirstDeclaredMethodByName(@Nonnull String name) { return methodStream() .filter(m -> m.getName().equals(name)) .findFirst().orElse(null); } /** * @param action * Action to run if this is a JVM class. */ void acceptIfJvmClass(@Nonnull Consumer action); /** * @param action * Action to run if this is an Android class. */ void acceptIfAndroidClass(@Nonnull Consumer action); /** * @param action * Action to run. */ default void acceptClass(@Nonnull Consumer action) { action.accept(this); } /** * @param predicate * Predicate to run if this is a JVM class. * * @return {@code true} when the predicate passes. * {@code false} when it does not, or the class is not a JVM class. */ boolean testIfJvmClass(@Nonnull Predicate predicate); /** * @param predicate * Predicate to run if this is an Android class. * * @return {@code true} when the predicate passes. * {@code false} when it does not, or the class is not an Android class. */ boolean testIfAndroidClass(@Nonnull Predicate predicate); /** * @param predicate * Predicate to run. * * @return Predicate evaluation. */ default boolean testClass(@Nonnull Predicate predicate) { return predicate.test(this); } /** * @param function * Mapping function. * @param * Function return type. * * @return Mapped value. */ default R mapClass(@Nonnull Function function) { return function.apply(this); } @Nonnull @Override default ClassInfo asClass() { return this; } @Nonnull @Override default FileInfo asFile() { throw new IllegalStateException("Class cannot be cast to generic file"); } /** * @return Self cast to JVM class. */ @Nonnull JvmClassInfo asJvmClass(); /** * @return Self cast to Android class. */ @Nonnull AndroidClassInfo asAndroidClass(); @Override default boolean isClass() { return true; } @Override default boolean isFile() { return false; } /** * @return {@code true} if self is a JVM class. */ boolean isJvmClass(); /** * @return {@code true} if self is an Android class. */ boolean isAndroidClass(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/DexFileInfo.java ================================================ package software.coley.recaf.info; /** * Outline of an Android dex file. * * @author Matt Coley */ public interface DexFileInfo extends FileInfo { } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/FileInfo.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.builder.FileInfoBuilder; /** * Outline of a file. * * @author Matt Coley */ public interface FileInfo extends Info { /** * @return New builder wrapping this file information. */ @Nonnull default FileInfoBuilder toFileBuilder() { return FileInfoBuilder.forFile(this); } /** * @return Raw bytes of file content. */ @Nonnull byte[] getRawContent(); /** * @return File extension of {@link #getName() the file name}, if any exists. */ @Nullable default String getFileExtension() { String fileName = getName(); int i = fileName.indexOf('.') + 1; if (i > 0) return fileName.toLowerCase().substring(i); return null; } /** * @return Directory the file resides in. * May be {@code null} for files in the root directory. */ @Nullable default String getDirectoryName() { String fileName = getName(); int directoryIndex = fileName.lastIndexOf('/'); if (directoryIndex <= 0) return null; return fileName.substring(0, directoryIndex); } @Nonnull @Override default ClassInfo asClass() { throw new IllegalStateException("File cannot be cast to generic class"); } @Nonnull @Override default FileInfo asFile() { return this; } /** * @return Self cast to text file. */ @Nonnull default TextFileInfo asTextFile() { throw new IllegalStateException("Non-text file cannot be cast to text file"); } /** * @return Self cast to image file. */ @Nonnull default ImageFileInfo asImageFile() { throw new IllegalStateException("Non-image file cannot be cast to image file"); } /** * @return Self cast to audio file. */ @Nonnull default AudioFileInfo asAudioFile() { throw new IllegalStateException("Non-audio file cannot be cast to audio file"); } /** * @return Self cast to video file. */ @Nonnull default VideoFileInfo asVideoFile() { throw new IllegalStateException("Non-video file cannot be cast to video file"); } /** * @return Self cast to video file. */ @Nonnull default NativeLibraryFileInfo asNativeLibraryFile() { throw new IllegalStateException("Non-native-library file cannot be cast to native-library file"); } /** * @return Self cast to zip file. */ @Nonnull default ZipFileInfo asZipFile() { throw new IllegalStateException("Non-zip file cannot be cast to zip file"); } @Override default boolean isClass() { return false; } @Override default boolean isFile() { return true; } /** * @return {@code true} if self is a text file. */ default boolean isTextFile() { return false; } /** * @return {@code true} if self is an image file. */ default boolean isImageFile() { return false; } /** * @return {@code true} if self is an audio file. */ default boolean isAudioFile() { return false; } /** * @return {@code true} if self is a video file. */ default boolean isVideoFile() { return false; } /** * @return {@code true} if self is a native-library file. */ default boolean isNativeLibraryFile() { return false; } /** * @return {@code true} if self is a zip file. */ default boolean isZipFile() { return false; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/ImageFileInfo.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; /** * Outline of an image file. * * @author Matt Coley */ public interface ImageFileInfo extends FileInfo { @Nonnull @Override default ImageFileInfo asImageFile() { return this; } @Override default boolean isImageFile() { return true; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/Info.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; import software.coley.recaf.info.properties.PropertyContainer; /** * Outline of all info types. * * @author Matt Coley */ public interface Info extends Named, PropertyContainer { /** * @return Self cast to general class. */ @Nonnull ClassInfo asClass(); /** * @return Self cast to general file. */ @Nonnull FileInfo asFile(); /** * @return {@code true} if self is a general class. */ boolean isClass(); /** * @return {@code true} if self is a general file. */ boolean isFile(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/InnerClassInfo.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; /** * Outline of an inner class for a declaring {@link ClassInfo}. *
* Important note: Oracle's Java Virtual Machine implementation * does not check the consistency of an InnerClasses attribute against a class * file representing a class or interface referenced by the attribute. * * @author Matt Coley * @author Amejonah */ public interface InnerClassInfo extends Accessed, Named { @Nonnull @Override default String getName() { return getInnerClassName(); } /** * @return The name of the outer declaring class for this inner class. */ @Nonnull String getOuterDeclaringClassName(); /** * Given the following example, the inner name is {@code Apple$Worm}. *
	 * class Apple {
	 *     class Worm {}
	 * }
	 * 
* * @return The internal name of an inner class. */ @Nonnull String getInnerClassName(); /** * Given the following example, the inner name is {@code Apple}. *
	 * class Apple {
	 *     class Worm {}
	 * }
	 * 
* * @return The internal name of the class to which the inner class belongs. * May be {@code null} for anonymous classes. */ @Nullable String getOuterClassName(); /** * Given the following example, the inner name is {@code Worm}. *
	 * class Apple {
	 *     class Worm {}
	 * }
	 * 
* * @return The (simple) name of the inner class inside its enclosing class. * May be {@code null} for anonymous inner classes. */ @Nullable String getInnerName(); /** * There are some wierd cases where there can be inner-class entries of classes defined by other classes. * You can use this to filter those cases out. * * @return {@code true} when this inner-class entry denotes an inner-class * reference to a class defined in another class. */ default boolean isExternalReference() { return !getInnerClassName().startsWith(getOuterDeclaringClassName()); } /** * @return The access modifiers of the inner class as originally declared in the enclosing class. */ default int getInnerAccess() { return getAccess(); } /** * @return Either {@link #getInnerName()} if not {@code null}, * otherwise the last "part" (after last $ or /) of {@link #getOuterClassName()}. */ @Nonnull default String getSimpleName() { // Check for inner name String innerName = getInnerName(); if (innerName != null) return innerName; // Substring from outer class prefix String outerDeclaringClass = getOuterDeclaringClassName(); String outerName = getOuterClassName(); if (outerName != null) { int outerDeclaringLength = outerDeclaringClass.length(); int lastIndex = 0; int endIndex = Math.min(outerDeclaringLength, outerName.length()); for (; lastIndex < endIndex; lastIndex++) { if (outerDeclaringClass.charAt(lastIndex) != outerName.charAt(lastIndex)) break; } // Edge case handling with outer name if (lastIndex == 0) return outerName; else if (outerName.startsWith("$", lastIndex)) lastIndex++; return outerName.substring(lastIndex); } // Class entry is for anonymous class. String innerClassName = getInnerClassName(); return "Anonymous '" + innerClassName.substring(innerClassName.lastIndexOf('$') + 1) + "'"; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/JModFileInfo.java ================================================ package software.coley.recaf.info; /** * Outline of a JVM JMod file. * These files are found at {@code %JAVA%/jmods/}. * * @author Matt Coley */ public interface JModFileInfo extends ZipFileInfo { /** * Classes in the JMod archive are prefixed with this path. */ String CLASSES_PREFIX = "classes/"; } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/JarFileInfo.java ================================================ package software.coley.recaf.info; /** * Outline of a JAR file container. * * @author Matt Coley */ public interface JarFileInfo extends ZipFileInfo { /** * Multi-release JARs prefix class names with this, plus the target version. * For example: Multiple versions of {@code foo/Bar.class} *
    *
  • {@code foo/Bar.class}
  • *
  • {@code META-INF/versions/9/foo/Bar.class}
  • *
  • {@code META-INF/versions/11/foo/Bar.class}
  • *
* The first item is used for Java 8.
* The second item for Java 9 and 10.
* The third item for Java 11+. */ String MULTI_RELEASE_PREFIX = "META-INF/versions/"; } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/JvmClassInfo.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; import org.objectweb.asm.ClassReader; import org.objectweb.asm.Opcodes; import org.objectweb.asm.Type; import software.coley.cafedude.classfile.ConstantPoolConstants; import software.coley.recaf.info.builder.JvmClassInfoBuilder; import software.coley.recaf.info.properties.builtin.ReferencedClassesProperty; import software.coley.recaf.info.properties.builtin.StringDefinitionsProperty; import software.coley.recaf.util.JavaVersion; import software.coley.recaf.util.Types; import software.coley.recaf.util.visitors.TypeVisitor; import java.util.*; import java.util.function.Consumer; import java.util.function.Predicate; /** * Outline of a JVM class. * * @author Matt Coley */ public interface JvmClassInfo extends ClassInfo { /** * Denotes the base version offset. *
    *
  • For version 1 of you would use {@code BASE_VERSION + 1}.
  • *
  • For version 2 of you would use {@code BASE_VERSION + 2}.
  • *
  • ...
  • *
  • For version N of you would use {@code BASE_VERSION + N}.
  • *
*/ int BASE_VERSION = 44; /** * @return New builder wrapping this class information. */ @Nonnull default JvmClassInfoBuilder toJvmClassBuilder() { return new JvmClassInfoBuilder(this); } /** * @return Java class file version. */ int getVersion(); /** * @return Bytecode of class. */ @Nonnull byte[] getBytecode(); /** * @return Class reader of {@link #getBytecode()}. */ @Nonnull ClassReader getClassReader(); /** * @return Default flags to use with {@link #getClassReader()} */ default int getClassReaderFlags() { // There are some old classes with stack-frame data. // These aren't strictly required on old classes, and ASM dies when re-writing them ( // - MethodWriter.visitFrame checks for pre Java 6 and throws return getVersion() <= Opcodes.V1_5 ? ClassReader.SKIP_FRAMES : 0; } /** * @return Set of all classes referenced in the constant pool. */ @Nonnull default NavigableSet getReferencedClasses() { NavigableSet classes = ReferencedClassesProperty.get(this); if (classes != null) return classes; Set classNames = new HashSet<>(); ClassReader reader = getClassReader(); // Iterate over pool entries. Supe fast way to discover most of the referenced types. int itemCount = reader.getItemCount(); char[] buffer = new char[reader.getMaxStringLength()]; for (int i = 1; i < itemCount; i++) { int offset = reader.getItem(i); if (offset >= 10) { try { int itemTag = reader.readByte(offset - 1); if (itemTag == ConstantPoolConstants.CLASS) { String className = reader.readUTF8(offset, buffer); if (className.isEmpty()) continue; addName(className, classNames); } else if (itemTag == ConstantPoolConstants.NAME_TYPE) { String desc = reader.readUTF8(offset + 2, buffer); if (desc.isEmpty()) continue; if (desc.charAt(0) == '(') { addMethodType(Type.getMethodType(desc), classNames); } else { Type type = Type.getType(desc); addType(type, classNames); } } else if (itemTag == ConstantPoolConstants.METHOD_TYPE) { String methodDesc = reader.readUTF8(offset, buffer); if (methodDesc.isEmpty() || methodDesc.charAt(0) != '(') continue; addMethodType(Type.getMethodType(methodDesc), classNames); } } catch (Throwable ignored) { // Exists only to catch situations where obfuscators put unused junk pool entries // with malformed descriptors, which cause ASM's type parser to crash. } } } // In some cases like interface classes, there may be UTF8 pool entries outlining method descriptors which // are not directly linked in NameType or MethodType pool entries. We need to iterate over fields and methods // to get the descriptors in these cases. reader.accept(new TypeVisitor(t -> { if (t.getSort() == Type.METHOD) addMethodType(t, classNames); else addType(t, classNames); }), ClassReader.SKIP_DEBUG | ClassReader.SKIP_CODE); return ReferencedClassesProperty.set(this, classNames); } private static void addMethodType(@Nonnull Type methodType, @Nonnull Set classNames) { for (Type argumentType : methodType.getArgumentTypes()) addType(argumentType, classNames); Type returnType = methodType.getReturnType(); addType(returnType, classNames); } private static void addType(@Nonnull Type type, @Nonnull Set classNames) { if (type.getSort() == Type.ARRAY) type = type.getElementType(); if (!Types.isPrimitive(type)) addName(type.getInternalName(), classNames); } private static void addName(@Nonnull String className, @Nonnull Set classNames) { if (className.isEmpty()) return; if (className.indexOf(0) == '[' || className.charAt(className.length() - 1) == ';') addType(Type.getType(className), classNames); else if (className.indexOf(0) == '(') addMethodType(Type.getMethodType(className), classNames); else classNames.add(className); } /** * @return Set of all string constants listed in the constant pool. */ @Nonnull default Set getStringConstants() { SortedSet strings = StringDefinitionsProperty.get(this); if (strings != null) return strings; Set stringSet = new HashSet<>(); ClassReader reader = getClassReader(); int itemCount = reader.getItemCount(); char[] buffer = new char[reader.getMaxStringLength()]; for (int i = 1; i < itemCount; i++) { int offset = reader.getItem(i); if (offset >= 10) { int itemTag = reader.readByte(offset - 1); if (itemTag == ConstantPoolConstants.STRING) { String string = reader.readUTF8(offset, buffer); stringSet.add(string); } } } StringDefinitionsProperty.set(this, stringSet); return stringSet; } @Override default void acceptIfJvmClass(@Nonnull Consumer action) { action.accept(this); } @Override default void acceptIfAndroidClass(@Nonnull Consumer action) { // no-op } @Override default boolean testIfJvmClass(@Nonnull Predicate predicate) { return predicate.test(this); } @Override default boolean testIfAndroidClass(@Nonnull Predicate predicate) { return false; } @Nonnull @Override default JvmClassInfo asJvmClass() { return this; } @Nonnull @Override default AndroidClassInfo asAndroidClass() { throw new IllegalStateException("JVM class cannot be cast to Android class"); } @Override default boolean isJvmClass() { return true; } @Override default boolean isAndroidClass() { return false; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/ModulesFileInfo.java ================================================ package software.coley.recaf.info; /** * Outline of a JVM modules file. * This file is found at {@code %JAVA%/lib/modules}. * * @author Matt Coley */ public interface ModulesFileInfo extends FileInfo { } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/Named.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator; import software.coley.recaf.util.StringUtil; import java.util.Comparator; import java.util.Objects; /** * Outline of a type that can be identified by name. * * @author Matt Coley */ public interface Named { /** * Comparator for {@link String} items. * First compares case-insensitively with natural ordering, then falls back to case-sensitive comparison. */ Comparator STRING_COMPARATOR = (a, b) -> { // Fallback to natural string comparison, first case-insensitive but then case-sensitive to differentiate. int cmp = CaseInsensitiveSimpleNaturalComparator.getInstance().compare(a, b); if (cmp == 0) cmp = a.compareTo(b); return cmp; }; /** * Comparator for {@link Named} items. * First compares case-insensitively with natural ordering, then falls back to case-sensitive comparison. * * @see #STRING_COMPARATOR */ Comparator NAMED_COMPARATOR = (o1, o2) -> { String a = o1.getName(); String b = o2.getName(); return STRING_COMPARATOR.compare(a, b); }; /** * Comparator for {@link String} items whose content represent file paths. */ @SuppressWarnings("StringEquality") Comparator STRING_PATH_COMPARATOR = (a, b) -> { // Get parent directory path for each item. String directoryPathA = StringUtil.cutOffAtLast(a, '/'); String directoryPathB = StringUtil.cutOffAtLast(b, '/'); if (!Objects.equals(directoryPathA, directoryPathB)) { // The directory path is the input path (same reference) if there is no '/'. // We always want root paths to be shown first since we group them in a container directory anyways. if (directoryPathA == a && directoryPathB != b) return -1; if (directoryPathA != a && directoryPathB == b) return 1; // We want subdirectories to be shown first over files in the directory. // The top-level directory being an empty string is an edge case. if (directoryPathA.isEmpty()) return -1; else if (directoryPathB.isEmpty()) return 1; // If both paths have the same number of separators, then we can do a normal string comparison on the directory paths. // If they have different numbers of separators, then we want to check if one is a parent of the other (or vice versa). int sectionCountA = StringUtil.count('/', directoryPathA); int sectionCountB = StringUtil.count('/', directoryPathB); if (sectionCountA == sectionCountB) { // Compare directories as paths. int cmp = STRING_COMPARATOR.compare(directoryPathA, directoryPathB); if (cmp != 0) return cmp; } else { // Check for parent-child relationship between the directory paths. The parent directory should be shown first. if (directoryPathB.startsWith(directoryPathA)) return 1; else if (directoryPathA.startsWith(directoryPathB)) return -1; } } return STRING_COMPARATOR.compare(a, b); }; /** * Comparator for {@link Named} items whose names represent file paths. * * @see #STRING_PATH_COMPARATOR */ Comparator NAMED_PATH_COMPARATOR = (o1, o2) -> { String a = o1.getName(); String b = o2.getName(); return STRING_PATH_COMPARATOR.compare(a, b); }; /** * @return Identifying name. */ @Nonnull String getName(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/NativeLibraryFileInfo.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; /** * Outline of a native library or application file. * * @author Matt Coley */ public interface NativeLibraryFileInfo extends FileInfo { @Nonnull @Override default NativeLibraryFileInfo asNativeLibraryFile() { return this; } @Override default boolean isNativeLibraryFile() { return true; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/StubClassInfo.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.objectweb.asm.ClassReader; import software.coley.recaf.info.annotation.AnnotationInfo; import software.coley.recaf.info.annotation.TypeAnnotationInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.info.properties.Property; import software.coley.recaf.util.ExcludeFromJacocoGeneratedReport; import software.coley.recaf.util.JavaVersion; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.function.Consumer; import java.util.function.Predicate; /** * Stub implementation of {@link ClassInfo}. * * @author Matt Coley */ @ExcludeFromJacocoGeneratedReport(justification = "Stub/placeholder type") public class StubClassInfo implements ClassInfo { private final String name; private final List fields; private final List methods; /** * @param name * Class name. */ public StubClassInfo(@Nonnull String name) { this(name, Collections.emptyList(), Collections.emptyList()); } /** * @param name * Class name. * @param fields * Fields to include. * @param methods * Methods to include. */ public StubClassInfo(@Nonnull String name, @Nonnull List fields, @Nonnull List methods) { this.name = name; this.fields = fields; this.methods = methods; } @Override public int getAccess() { return 0; } @Nullable @Override public String getSourceFileName() { return null; } @Nonnull @Override public List getInterfaces() { return Collections.emptyList(); } @Nullable @Override public String getSuperName() { return "java/lang/Object"; } @Nullable @Override public String getSignature() { return null; } @Nullable @Override public String getOuterClassName() { return null; } @Nullable @Override public String getOuterMethodName() { return null; } @Nullable @Override public String getOuterMethodDescriptor() { return null; } @Nonnull @Override public List getOuterClassBreadcrumbs() { return Collections.emptyList(); } @Nonnull @Override public List getInnerClasses() { return Collections.emptyList(); } @Nonnull @Override public List getFields() { return fields; } @Nonnull @Override public List getMethods() { return methods; } @Override public void acceptIfJvmClass(@Nonnull Consumer action) { // no-op } @Override public void acceptIfAndroidClass(@Nonnull Consumer action) { // no-op } @Override public boolean testIfJvmClass(@Nonnull Predicate predicate) { return false; } @Override public boolean testIfAndroidClass(@Nonnull Predicate predicate) { return false; } @Override public boolean isJvmClass() { return false; } @Override public boolean isAndroidClass() { return false; } @Nonnull @Override public String getName() { return name; } @Nonnull @Override public List getAnnotations() { return Collections.emptyList(); } @Nonnull @Override public List getTypeAnnotations() { return Collections.emptyList(); } @Override public void setProperty(Property property) { // no-op } @Override public void removeProperty(String key) { // no-op } @Nonnull @Override public Map> getProperties() { return Collections.emptyMap(); } @Nonnull @Override public JvmClassInfo asJvmClass() { return new Jvm(name); } @Nonnull @Override public AndroidClassInfo asAndroidClass() { return new Android(name); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; StubClassInfo that = (StubClassInfo) o; return name.equals(that.name); } @Override public int hashCode() { return name.hashCode(); } @Override public String toString() { return name; } private static class Android extends StubClassInfo implements AndroidClassInfo { public Android(@Nonnull String name) { super(name); } @Nonnull @Override public AndroidClassInfo asAndroidClass() { return this; } } private static class Jvm extends StubClassInfo implements JvmClassInfo { public Jvm(@Nonnull String name) { super(name); } @Nonnull @Override public JvmClassInfo asJvmClass() { return this; } @Override public int getVersion() { return JavaVersion.VERSION_OFFSET + JavaVersion.get(); } @Nonnull @Override public byte[] getBytecode() { return new byte[0]; } @Nonnull @Override public ClassReader getClassReader() { throw new IllegalStateException("Cannot read from stub class!"); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/StubFieldMember.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.util.ExcludeFromJacocoGeneratedReport; /** * Stub implementation of {@link FieldMember}. * * @author Matt Coley */ @ExcludeFromJacocoGeneratedReport(justification = "Stub/placeholder type") public class StubFieldMember extends StubMember implements FieldMember { /** * @param name * Field name. * @param desc * Field descriptor. * @param access * Field access flags. */ public StubFieldMember(@Nonnull String name, @Nonnull String desc, int access) { super(name, desc, access); } @Nullable @Override public Object getDefaultValue() { return null; } @Override public String toString() { return getDescriptor() + ' ' + getName(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/StubFileInfo.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; import software.coley.recaf.info.properties.Property; import software.coley.recaf.util.ExcludeFromJacocoGeneratedReport; import software.coley.recaf.util.StringUtil; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.Map; /** * Stub implementation of {@link FileInfo}. * * @author Matt Coley */ @ExcludeFromJacocoGeneratedReport(justification = "Stub/placeholder type") public class StubFileInfo implements FileInfo { private final String name; /** * @param name * File name. */ public StubFileInfo(@Nonnull String name) { this.name = name; } /** * @param text * Text to assign. * * @return This file, with text content. */ @Nonnull public TextFileInfo withText(@Nonnull String text) { return withText(StandardCharsets.ISO_8859_1, text); } /** * @param charset * Charset of text. * @param text * Text to assign. * * @return This file, with text content. */ @Nonnull public TextFileInfo withText(@Nonnull Charset charset, @Nonnull String text) { return new StubTextFileInfo(name, charset, text); } @Nonnull @Override public byte[] getRawContent() { return new byte[0]; } @Nonnull @Override public String getName() { return name; } @Override public void setProperty(Property property) { // no-op } @Override public void removeProperty(String key) { // no-op } @Nonnull @Override public Map> getProperties() { return Collections.emptyMap(); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null) return false; if (o instanceof FileInfo other) { return name.equals(other.getName()); } return false; } @Override public int hashCode() { return name.hashCode(); } private static class StubTextFileInfo extends StubFileInfo implements TextFileInfo { private final String text; private final Charset charset; /** * @param name * File name. * @param charset * Charset of text. * @param text * Text content. */ public StubTextFileInfo(@Nonnull String name, @Nonnull Charset charset, @Nonnull String text) { super(name); this.text = text; this.charset = charset; } @Nonnull @Override public String getText() { return text; } @Nonnull @Override public String[] getTextLines() { return StringUtil.splitNewline(text); } @Nonnull @Override public Charset getCharset() { return charset; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null) return false; if (!super.equals(o)) return false; if (o instanceof TextFileInfo other) { return (text.equals(other.getText())); } return false; } @Override public int hashCode() { int result = super.hashCode(); result = 31 * result + text.hashCode(); return result; } @Override public String toString() { return getName() + " : <" + text + ">"; } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/StubMember.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.annotation.AnnotationInfo; import software.coley.recaf.info.annotation.TypeAnnotationInfo; import software.coley.recaf.info.member.ClassMember; import software.coley.recaf.info.properties.Property; import software.coley.recaf.util.ExcludeFromJacocoGeneratedReport; import java.util.Collections; import java.util.List; import java.util.Map; /** * Stub implementation of {@link ClassMember}. * * @author Matt Coley */ @ExcludeFromJacocoGeneratedReport(justification = "Stub/placeholder type") public abstract class StubMember implements ClassMember { private final String name; private final String desc; private final int access; /** * @param name Member name. * @param desc Member descriptor. * @param access Member access flags. */ public StubMember(@Nonnull String name, @Nonnull String desc, int access) { this.name = name; this.desc = desc; this.access = access; } @Nonnull @Override public String getName() { return name; } @Nonnull @Override public String getDescriptor() { return desc; } @Nullable @Override public String getSignature() { return null; } @Override public int getAccess() { return access; } @Nonnull @Override public List getAnnotations() { return Collections.emptyList(); } @Nonnull @Override public List getTypeAnnotations() { return Collections.emptyList(); } @Override public void setProperty(Property property) {} @Override public void removeProperty(String key) {} @Nonnull @Override public Map> getProperties() { return Collections.emptyMap(); } @Override public final boolean equals(Object o) { if (!(o instanceof StubMember that)) return false; return access == that.access && name.equals(that.name) && desc.equals(that.desc); } @Override public int hashCode() { int result = name.hashCode(); result = 31 * result + desc.hashCode(); result = 31 * result + access; return result; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/StubMethodMember.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.annotation.AnnotationElement; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.util.ExcludeFromJacocoGeneratedReport; import java.util.Collections; import java.util.List; /** * Stub implementation of {@link MethodMember}. * * @author Matt Coley */ @ExcludeFromJacocoGeneratedReport(justification = "Stub/placeholder type") public class StubMethodMember extends StubMember implements MethodMember { /** * @param name * Method name. * @param desc * Method descriptor. * @param access * Method access flags. */ public StubMethodMember(@Nonnull String name, @Nonnull String desc, int access) { super(name, desc, access); } @Nonnull @Override public List getThrownTypes() { return Collections.emptyList(); } @Nonnull @Override public List getLocalVariables() { return Collections.emptyList(); } @Nullable @Override public AnnotationElement getAnnotationDefault() { return null; } @Override public String toString() { return getName() + getDescriptor(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/TextFileInfo.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; import software.coley.recaf.info.builder.TextFileInfoBuilder; import java.nio.charset.Charset; /** * Outline of a text file. * * @author Matt Coley */ public interface TextFileInfo extends FileInfo { /** * @return New builder wrapping this file information. */ @Nonnull default TextFileInfoBuilder toTextBuilder() { return new TextFileInfoBuilder(this); } /** * @return The {@link #getRawContent()} as text. */ @Nonnull String getText(); /** * @return The {@link #getText() text content} split into lines. */ @Nonnull String[] getTextLines(); /** * @return The charset used to encode {@link #getText() the text content}. */ @Nonnull Charset getCharset(); @Nonnull @Override default TextFileInfo asTextFile() { return this; } @Override default boolean isTextFile() { return true; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/VideoFileInfo.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; /** * Outline of a video file. * * @author Matt Coley */ public interface VideoFileInfo extends FileInfo { @Nonnull @Override default VideoFileInfo asVideoFile() { return this; } @Override default boolean isVideoFile() { return true; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/WarFileInfo.java ================================================ package software.coley.recaf.info; /** * Outline of a WAR file container. * * @author Matt Coley */ public interface WarFileInfo extends ZipFileInfo { /** * WAR files prefix their class names with this. */ String WAR_CLASS_PREFIX = "WEB-INF/classes/"; } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/ZipFileInfo.java ================================================ package software.coley.recaf.info; import jakarta.annotation.Nonnull; /** * Outline of a ZIP file container. * * @author Matt Coley * @see JarFileInfo * @see WarFileInfo * @see JModFileInfo * @see ApkFileInfo */ public interface ZipFileInfo extends FileInfo { @Nonnull @Override default ZipFileInfo asZipFile() { return this; } @Override default boolean isZipFile() { return true; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/annotation/Annotated.java ================================================ package software.coley.recaf.info.annotation; import jakarta.annotation.Nonnull; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.ClassMember; import java.util.List; import java.util.stream.Stream; /** * Outline of an annotated class or member. * * @author Matt Coley * @see ClassInfo * @see ClassMember */ public interface Annotated { /** * @return List of declared annotations. */ @Nonnull List getAnnotations(); /** * @return List of type annotations. */ @Nonnull List getTypeAnnotations(); /** * @return Stream of declared annotations. */ @Nonnull default Stream annotationStream() { return Stream.of(this).flatMap(self -> self.getAnnotations().stream()); } /** * @return Stream of type annotations. */ @Nonnull default Stream typeAnnotationStream() { return Stream.of(this).flatMap(self -> self.getAnnotations().stream()); } /** * @return Stream of both normal and type anotations. */ @Nonnull default Stream allAnnotationsStream() { return Stream.concat(annotationStream(), typeAnnotationStream()); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/annotation/AnnotationArrayReference.java ================================================ package software.coley.recaf.info.annotation; import jakarta.annotation.Nonnull; import java.util.List; /** * Outline of a potential value for {@link AnnotationElement#getElementValue()}. * * @author Matt Coley */ public interface AnnotationArrayReference { /** * @return List of values. */ @Nonnull List getValues(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/annotation/AnnotationElement.java ================================================ package software.coley.recaf.info.annotation; import jakarta.annotation.Nonnull; import org.objectweb.asm.Type; import java.util.List; /** * Outline of an annotation member. * * @author Matt Coley */ public interface AnnotationElement { /** * @return Element name. */ @Nonnull String getElementName(); /** * @return Element value. Can be a primitive, {@link String}, a {@link AnnotationInfo}, * a {@link AnnotationEnumReference}, a {@link Type}, or a {@link List} of any of the prior values. */ @Nonnull Object getElementValue(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/annotation/AnnotationEnumReference.java ================================================ package software.coley.recaf.info.annotation; import jakarta.annotation.Nonnull; /** * Outline of an enum reference for {@link AnnotationElement#getElementValue()}. * * @author Matt Coley */ public interface AnnotationEnumReference { /** * @return Descriptor of enum value. */ @Nonnull String getDescriptor(); /** * @return Enum value name. */ @Nonnull String getValue(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/annotation/AnnotationInfo.java ================================================ package software.coley.recaf.info.annotation; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.objectweb.asm.TypePath; import java.util.Map; /** * Outline of annotation data. * * @author Matt Coley */ public interface AnnotationInfo extends Annotated { /** * @param typeRef * Constant denoting where the annotation is applied. * @param typePath * Path to a type argument. * * @return Type annotation from this annotation. */ @Nonnull TypeAnnotationInfo withTypeInfo(int typeRef, @Nullable TypePath typePath); /** * @return {@code true} if the annotation is visible at runtime. */ boolean isVisible(); /** * @return Annotation descriptor. */ @Nonnull String getDescriptor(); /** * @return Annotation elements. */ @Nonnull Map getElements(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/annotation/BasicAnnotationArrayReference.java ================================================ package software.coley.recaf.info.annotation; import jakarta.annotation.Nonnull; import java.util.List; import java.util.stream.Collectors; /** * Basic implementation of an annotation array reference in an element. * * @author Matt Coley */ public class BasicAnnotationArrayReference implements AnnotationArrayReference { private final List values; /** * @param values * Array values. */ public BasicAnnotationArrayReference(List values) { this.values = values; } @Nonnull public List getValues() { return values; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; BasicAnnotationArrayReference that = (BasicAnnotationArrayReference) o; return values.equals(that.values); } @Override public int hashCode() { return values.hashCode(); } @Override public String toString() { return "[" + values.stream() .map(Object::toString) .collect(Collectors.joining(", ")) + "]"; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/annotation/BasicAnnotationElement.java ================================================ package software.coley.recaf.info.annotation; import jakarta.annotation.Nonnull; import java.util.Objects; /** * Basic implementation of annotation elements. * * @author Matt Coley */ public class BasicAnnotationElement implements AnnotationElement { private final String name; private final Object value; /** * @param name * Element name. * @param value * Element value. */ public BasicAnnotationElement(String name, Object value) { this.name = name; this.value = value; } @Nonnull @Override public String getElementName() { return name; } @Nonnull @Override public Object getElementValue() { return value; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; BasicAnnotationElement element = (BasicAnnotationElement) o; if (!name.equals(element.name)) return false; return Objects.equals(value, element.value); } @Override public int hashCode() { int result = name.hashCode(); result = 31 * result + (value != null ? value.hashCode() : 0); return result; } @Override public String toString() { return "BasicAnnotationElement{" + "name='" + name + '\'' + ", value=" + value + '}'; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/annotation/BasicAnnotationEnumReference.java ================================================ package software.coley.recaf.info.annotation; import jakarta.annotation.Nonnull; /** * Basic implementation of an annotation array reference in an element. * * @author Matt Coley */ public class BasicAnnotationEnumReference implements AnnotationEnumReference { private final String descriptor; private final String value; /** * @param descriptor * Enum descriptor. * @param value * Enum name. */ public BasicAnnotationEnumReference(String descriptor, String value) { this.descriptor = descriptor; this.value = value; } @Nonnull @Override public String getDescriptor() { return descriptor; } @Nonnull @Override public String getValue() { return value; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; BasicAnnotationEnumReference enumValue = (BasicAnnotationEnumReference) o; if (!descriptor.equals(enumValue.descriptor)) return false; return value.equals(enumValue.value); } @Override public int hashCode() { int result = descriptor.hashCode(); result = 31 * result + value.hashCode(); return result; } @Override public String toString() { return descriptor + ":" + value; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/annotation/BasicAnnotationInfo.java ================================================ package software.coley.recaf.info.annotation; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.objectweb.asm.TypePath; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; /** * Basic implementation of annotation info. * * @author Matt Coley */ public class BasicAnnotationInfo implements AnnotationInfo { private final Map elements = new LinkedHashMap<>(); // preserve order on iter private final List annotations = new ArrayList<>(); private final List typeAnnotations = new ArrayList<>(); private final boolean visible; private final String descriptor; /** * @param visible * Annotation runtime visibility. * @param descriptor * Annotation descriptor. */ public BasicAnnotationInfo(boolean visible, @Nonnull String descriptor) { this.visible = visible; this.descriptor = descriptor; } /** * For internal use when populating the model. * Adding an element here does not change the bytecode of the class. * * @param element * Element to add. */ public void addElement(@Nonnull AnnotationElement element) { elements.put(element.getElementName(), element); } /** * For internal use when populating the model. * Adding an annotation here does not change the bytecode of the class. * * @param annotation * Annotation to add. */ public void addAnnotation(@Nonnull AnnotationInfo annotation) { annotations.add(annotation); } /** * For internal use when populating the model. * Adding an annotation here does not change the bytecode of the class. * * @param typeAnnotation * Annotation to add. */ public void addTypeAnnotation(@Nonnull TypeAnnotationInfo typeAnnotation) { typeAnnotations.add(typeAnnotation); } @Nonnull @Override public BasicTypeAnnotationInfo withTypeInfo(int typeRef, @Nullable TypePath typePath) { return new BasicTypeAnnotationInfo(typeRef, typePath, isVisible(), getDescriptor()); } @Override public boolean isVisible() { return visible; } @Nonnull @Override public String getDescriptor() { return descriptor; } @Nonnull @Override public Map getElements() { return elements; } @Nonnull @Override public List getAnnotations() { return annotations; } @Nonnull @Override public List getTypeAnnotations() { return typeAnnotations; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; BasicAnnotationInfo annotation = (BasicAnnotationInfo) o; if (visible != annotation.visible) return false; if (!elements.equals(annotation.elements)) return false; return descriptor.equals(annotation.descriptor); } @Override public int hashCode() { int result = elements.hashCode(); result = 31 * result + (visible ? 1 : 0); result = 31 * result + descriptor.hashCode(); return result; } @Override public String toString() { return "BasicAnnotationInfo{" + " visible=" + visible + ", descriptor='" + descriptor + '\'' + ", elements=" + elements + '}'; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/annotation/BasicTypeAnnotationInfo.java ================================================ package software.coley.recaf.info.annotation; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.objectweb.asm.TypePath; import java.util.Objects; /** * Basic implementation of type annotation info. * * @author Matt Coley */ public class BasicTypeAnnotationInfo extends BasicAnnotationInfo implements TypeAnnotationInfo { private final int typeRef; private final TypePath typePath; /** * @param typeRef * Constant denoting where the annotation is applied. * @param typePath * Path to a type argument. * May be {@code null} if no path is required. * @param visible * Annotation runtime visibility. * @param descriptor * Annotation descriptor. */ public BasicTypeAnnotationInfo(int typeRef, @Nullable TypePath typePath, boolean visible, @Nonnull String descriptor) { super(visible, descriptor); this.typeRef = typeRef; this.typePath = typePath; } @Override public int getTypeRef() { return typeRef; } @Nullable @Override public TypePath getTypePath() { return typePath; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; if (!super.equals(o)) return false; BasicTypeAnnotationInfo that = (BasicTypeAnnotationInfo) o; if (typeRef != that.typeRef) return false; return Objects.equals(typePath, that.typePath); } @Override public int hashCode() { int result = super.hashCode(); result = 31 * result + typeRef; result = 31 * result + (typePath != null ? typePath.hashCode() : 0); return result; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/annotation/TypeAnnotationInfo.java ================================================ package software.coley.recaf.info.annotation; import jakarta.annotation.Nullable; import org.objectweb.asm.TypePath; import org.objectweb.asm.TypeReference; /** * Outline of type annotation data. * * @author Matt Coley */ public interface TypeAnnotationInfo extends AnnotationInfo { /** * @return Constant denoting where the annotation is applied. * * @see TypeReference For return values. */ int getTypeRef(); /** * @return Path to a type argument. May be {@code null} if no path is required. */ @Nullable TypePath getTypePath(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/builder/AbstractClassInfoBuilder.java ================================================ package software.coley.recaf.info.builder; import software.coley.recaf.info.Accessed; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.InnerClassInfo; import software.coley.recaf.info.annotation.AnnotationInfo; import software.coley.recaf.info.annotation.TypeAnnotationInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.info.properties.BasicPropertyContainer; import software.coley.recaf.info.properties.PropertyContainer; import java.util.Collections; import java.util.List; import java.util.Objects; /** * Common builder info for {@link ClassInfo}. * * @param * Self type. Exists so implementations don't get stunted in their chaining. * * @author Matt Coley * @see JvmClassInfoBuilder For {@link software.coley.recaf.info.JvmClassInfo} * @see AndroidClassInfoBuilder For {@link software.coley.recaf.info.AndroidClassInfo} */ public abstract class AbstractClassInfoBuilder> { private String name; private String superName = "java/lang/Object"; private List interfaces = Collections.emptyList(); private final AccessImpl access = new AccessImpl(); private String signature; private String sourceFileName; private List annotations = Collections.emptyList(); private List typeAnnotations = Collections.emptyList(); private String outerClassName; private String outerMethodName; private String outerMethodDescriptor; private List innerClasses = Collections.emptyList(); private List fields = Collections.emptyList(); private List methods = Collections.emptyList(); private PropertyContainer propertyContainer = new BasicPropertyContainer(); protected AbstractClassInfoBuilder() { // default } protected AbstractClassInfoBuilder(ClassInfo classInfo) { // copy state withName(classInfo.getName()); withSuperName(classInfo.getSuperName()); withInterfaces(classInfo.getInterfaces()); withAccess(classInfo.getAccess()); withSignature(classInfo.getSignature()); withSourceFileName(classInfo.getSourceFileName()); withAnnotations(classInfo.getAnnotations()); withTypeAnnotations(classInfo.getTypeAnnotations()); withOuterClassName(classInfo.getOuterClassName()); withOuterMethodName(classInfo.getOuterMethodName()); withOuterMethodDescriptor(classInfo.getOuterMethodDescriptor()); withInnerClasses(classInfo.getInnerClasses()); withFields(classInfo.getFields()); withMethods(classInfo.getMethods()); withPropertyContainer(new BasicPropertyContainer(classInfo.getPersistentProperties())); } @SuppressWarnings("unchecked") public static > B forClass(ClassInfo info) { if (info.isJvmClass()) { return (B) new JvmClassInfoBuilder(info.asJvmClass()); } else if (info.isAndroidClass()) { return (B) new AndroidClassInfoBuilder(info.asAndroidClass()); } throw new IllegalStateException("Unsupported class info type: " + info); } @SuppressWarnings("unchecked") public B withName(String name) { this.name = name; return (B) this; } @SuppressWarnings("unchecked") public B withSuperName(String superName) { this.superName = superName; return (B) this; } @SuppressWarnings("unchecked") public B withInterfaces(List interfaces) { if (interfaces == null) this.interfaces = Collections.emptyList(); else this.interfaces = interfaces; return (B) this; } @SuppressWarnings("unchecked") public B withAccess(int access) { this.access.value = access; return (B) this; } @SuppressWarnings("unchecked") public B withSignature(String signature) { this.signature = signature; return (B) this; } @SuppressWarnings("unchecked") public B withSourceFileName(String sourceFileName) { this.sourceFileName = sourceFileName; return (B) this; } @SuppressWarnings("unchecked") public B withAnnotations(List annotations) { if (annotations == null) this.annotations = Collections.emptyList(); else this.annotations = annotations; return (B) this; } @SuppressWarnings("unchecked") public B withTypeAnnotations(List typeAnnotations) { this.typeAnnotations = Objects.requireNonNullElse(typeAnnotations, Collections.emptyList()); return (B) this; } @SuppressWarnings("unchecked") public B withOuterClassName(String outerClassName) { this.outerClassName = outerClassName; return (B) this; } @SuppressWarnings("unchecked") public B withOuterMethodName(String outerMethodName) { this.outerMethodName = outerMethodName; return (B) this; } @SuppressWarnings("unchecked") public B withOuterMethodDescriptor(String outerMethodDescriptor) { this.outerMethodDescriptor = outerMethodDescriptor; return (B) this; } @SuppressWarnings("unchecked") public B withInnerClasses(List innerClasses) { if (innerClasses == null) this.innerClasses = Collections.emptyList(); else this.innerClasses = innerClasses; return (B) this; } @SuppressWarnings("unchecked") public B withFields(List fields) { if (fields == null) this.fields = Collections.emptyList(); else this.fields = fields; return (B) this; } @SuppressWarnings("unchecked") public B withMethods(List methods) { if (methods == null) this.methods = Collections.emptyList(); else this.methods = methods; return (B) this; } @SuppressWarnings("unchecked") public B withPropertyContainer(PropertyContainer propertyContainer) { this.propertyContainer = Objects.requireNonNullElseGet(propertyContainer, BasicPropertyContainer::new); return (B) this; } public String getName() { return name; } public String getSuperName() { return superName; } public List getInterfaces() { return interfaces; } public int getAccess() { return access.value; } public String getSignature() { return signature; } public String getSourceFileName() { return sourceFileName; } public List getAnnotations() { return annotations; } public List getTypeAnnotations() { return typeAnnotations; } public String getOuterClassName() { return outerClassName; } public String getOuterMethodName() { return outerMethodName; } public String getOuterMethodDescriptor() { return outerMethodDescriptor; } public List getInnerClasses() { return innerClasses; } public List getFields() { return fields; } public List getMethods() { return methods; } public PropertyContainer getPropertyContainer() { return propertyContainer; } public abstract ClassInfo build(); protected void verify() { if (name == null) throw new IllegalArgumentException("Name required"); if (superName == null && !access.hasModuleModifier() && !access.hasAnnotationModifier() && !name.equals("java/lang/Object")) throw new IllegalArgumentException("Super-name required"); } static class AccessImpl implements Accessed { private int value; @Override public int getAccess() { return value; } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/builder/AndroidClassInfoBuilder.java ================================================ package software.coley.recaf.info.builder; import com.android.tools.r8.graph.*; import com.google.common.collect.Streams; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.AndroidClassInfo; import software.coley.recaf.info.BasicAndroidClassInfo; import software.coley.recaf.info.annotation.AnnotationInfo; import software.coley.recaf.info.annotation.BasicAnnotationElement; import software.coley.recaf.info.annotation.BasicAnnotationInfo; import software.coley.recaf.info.member.*; import java.util.Collections; import java.util.List; import java.util.stream.Collectors; import static software.coley.recaf.util.NumberUtil.isNonZero; /** * Builder for {@link AndroidClassInfo}. * * @author Matt Coley */ public class AndroidClassInfoBuilder extends AbstractClassInfoBuilder { private DexProgramClass dexClass; /** * Create empty builder. */ public AndroidClassInfoBuilder() { super(); } /** * Create a builder with data pulled from the given class. * * @param classInfo * Class to pull data from. */ public AndroidClassInfoBuilder(AndroidClassInfo classInfo) { super(classInfo); } /** * @return Wrapped dex class, if any. Used as a source for information when adapted from. * * @see #adaptFrom(DexProgramClass) Where this value is set. */ public DexProgramClass getDexClass() { return dexClass; } @Override public AndroidClassInfo build() { verify(); return new BasicAndroidClassInfo(this); } /** * Copies over values by pulling values from the contents of the given class model. * * @param dexClass * D8 Class structure to pull data from. * * @return Builder. */ @Nonnull public AndroidClassInfoBuilder adaptFrom(@Nonnull DexProgramClass dexClass) { this.dexClass = dexClass; withName(dexClass.getTypeName().replace('.', '/')); withSuperName(dexClass.getSuperType().getTypeName().replace('.', '/')); withInterfaces(dexClass.getInterfaces().stream().map(i -> i.getTypeName().replace('.', '/')).toList()); withAccess(dexClass.getAccessFlags().getAsCfAccessFlags()); withSourceFileName(dexClass.getSourceFile() == null ? null : dexClass.getSourceFile().toString()); withAnnotations(mapAnnos(dexClass.annotations())); withFields(mapFields(dexClass.fields())); withMethods(mapMethods(dexClass.methods())); withSignature(dexClass.getClassSignature().toString()); InnerClassAttribute innerClasses = dexClass.getInnerClassAttributeForThisClass(); if (innerClasses != null) { DexType outerType = innerClasses.getOuter(); if (outerType != null) { withOuterClassName(outerType.getTypeName().replace('.', '/')); } } if (dexClass.hasEnclosingMethodAttribute()) { DexMethod enclosingMethod = dexClass.getEnclosingMethodAttribute().getEnclosingMethod(); if (enclosingMethod != null) { withOuterMethodName(enclosingMethod.getName().toString()); withOuterMethodDescriptor(enclosingMethod.getProto().toDescriptorString()); withOuterClassName(enclosingMethod.getHolderType().getTypeName().replace('.', '/')); } } return this; } @Nonnull private List mapFields(Iterable fields) { if (fields == null) return Collections.emptyList(); return Streams.stream(fields) .map(f -> { String name = f.getName().toString(); String desc = f.getType().toDescriptorString(); String sig = f.getGenericSignature().toString(); int access = f.accessFlags.getAsCfAccessFlags(); Object value = unbox(f.getStaticValue()); BasicFieldMember field = new BasicFieldMember(name, desc, sig, access, value); for (AnnotationInfo anno : mapAnnos(f.annotations())) field.addAnnotation(anno); return field; }) .collect(Collectors.toList()); } @Nonnull private List mapMethods(@Nullable Iterable methods) { if (methods == null) return Collections.emptyList(); return Streams.stream(methods) .map(m -> { String name = m.getName().toString(); String desc = m.getProto().toDescriptorString(); String sig = m.getSignature().toString(); int access = m.getAccessFlags().getAsCfAccessFlags(); List thrownTypes = Collections.emptyList(); BasicMethodMember method = new BasicMethodMember(name, desc, sig, access, thrownTypes); for (AnnotationInfo anno : mapAnnos(m.annotations())) method.addAnnotation(anno); return method; }) .collect(Collectors.toList()); } @Nonnull private static List mapAnnos(@Nullable DexAnnotationSet anns) { if (anns == null) return Collections.emptyList(); return anns.stream() .map(AndroidClassInfoBuilder::mapAnno) .collect(Collectors.toList()); } @Nonnull private static BasicAnnotationInfo mapAnno(@Nonnull DexAnnotation anno) { BasicAnnotationInfo info = new BasicAnnotationInfo(isNonZero(anno.getVisibility()), anno.getAnnotationType().getTypeName().replace('.', '/')); anno.annotation.forEachElement(element -> { String name = element.getName().toString(); Object unbox = unbox(element.getValue()); info.addElement(new BasicAnnotationElement(name, unbox)); }); return info; } @Nonnull private static BasicAnnotationInfo mapAnno(@Nonnull DexEncodedAnnotation anno) { BasicAnnotationInfo info = new BasicAnnotationInfo(true, anno.type.getTypeName().replace('.', '/')); for (DexAnnotationElement element : anno.elements) { String name = element.getName().toString(); DexValue value = element.getValue(); Object unbox = unbox(value); info.addElement(new BasicAnnotationElement(name, unbox)); } return info; } private static Object unbox(DexValue value) { if (value instanceof DexValue.DexValueString dexString) { return dexString.toString(); } else if (value instanceof DexValue.DexValueBoolean dexBoolean) { return dexBoolean.getValue(); } else if (value instanceof DexValue.DexValueByte dexByte) { return dexByte.getValue(); } else if (value instanceof DexValue.DexValueChar dexChar) { return dexChar.getValue(); } else if (value instanceof DexValue.DexValueShort dexShort) { return dexShort.getValue(); } else if (value instanceof DexValue.DexValueInt dexInt) { return dexInt.getValue(); } else if (value instanceof DexValue.DexValueFloat dexFloat) { return dexFloat.getValue(); } else if (value instanceof DexValue.DexValueLong dexLong) { return dexLong.getValue(); } else if (value instanceof DexValue.DexValueDouble dexFloat) { return dexFloat.getValue(); } else if (value instanceof DexValue.DexValueAnnotation dexAnnotation) { return mapAnno(dexAnnotation.getValue()); } else if (value instanceof DexValue.DexValueArray dexArray) { DexValue[] values = dexArray.getValues(); Object[] unboxed = new Object[values.length]; for (int i = 0; i < values.length; i++) { unboxed[i] = unbox(values[i]); } return unboxed; } else if (value instanceof DexValue.DexValueEnum dexEnum) { DexField field = dexEnum.getValue(); return field.getHolderType().getTypeName() + " " + field.getName() + field.getType().toDescriptorString(); } else if (value instanceof DexValue.DexValueField dexField) { DexField field = dexField.getValue(); return field.getHolderType().getTypeName() + " " + field.getName() + field.getType().toDescriptorString(); } else if (value instanceof DexValue.DexValueMethod dexMethod) { DexMethod method = dexMethod.getValue(); return method.getHolderType().getTypeName() + " " + method.getName() + method.getProto().toDescriptorString(); } else if (value instanceof DexValue.DexValueMethodHandle dexMethodHandle) { return dexMethodHandle.getValue().toAsmHandle(null); } else if (value instanceof DexValue.DexValueMethodType dexMethodType) { return dexMethodType.getValue().toDescriptorString(); } else if (value instanceof DexValue.DexValueType dexType) { return dexType.getValue().getTypeName(); } else if (value instanceof DexValue.DexValueNull) { return null; } throw new UnsupportedOperationException("Unsupported dex value type: " + value); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/builder/ApkFileInfoBuilder.java ================================================ package software.coley.recaf.info.builder; import jakarta.annotation.Nonnull; import software.coley.recaf.info.*; /** * Builder for {@link ApkFileInfo}. * * @author Matt Coley */ public class ApkFileInfoBuilder extends ZipFileInfoBuilder { public ApkFileInfoBuilder() { // empty } public ApkFileInfoBuilder(@Nonnull ApkFileInfo apkInfo) { super(apkInfo); } public ApkFileInfoBuilder(@Nonnull ZipFileInfoBuilder other) { super(other); } @Nonnull @Override public BasicApkFileInfo build() { return new BasicApkFileInfo(this); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/builder/ArscFileInfoBuilder.java ================================================ package software.coley.recaf.info.builder; import jakarta.annotation.Nonnull; import software.coley.recaf.info.ArscFileInfo; import software.coley.recaf.info.BasicArscFileInfo; import software.coley.recaf.info.BasicBinaryXmlFileInfo; /** * Builder for {@link BasicBinaryXmlFileInfo}. * * @author Matt Coley */ public class ArscFileInfoBuilder extends ChunkFileInfoBuilder { public ArscFileInfoBuilder() { // empty } public ArscFileInfoBuilder(ArscFileInfo arscInfo) { super(arscInfo); } @Nonnull @Override public BasicArscFileInfo build() { return new BasicArscFileInfo(this); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/builder/AudioFileInfoBuilder.java ================================================ package software.coley.recaf.info.builder; import jakarta.annotation.Nonnull; import software.coley.recaf.info.BasicAudioFileInfo; import software.coley.recaf.info.AudioFileInfo; /** * Builder for {@link AudioFileInfo}. * * @author Matt Coley */ public class AudioFileInfoBuilder extends FileInfoBuilder { public AudioFileInfoBuilder() { // empty } public AudioFileInfoBuilder(AudioFileInfo audioFileInfo) { super(audioFileInfo); } public AudioFileInfoBuilder(FileInfoBuilder other) { super(other); } @Nonnull @Override public BasicAudioFileInfo build() { return new BasicAudioFileInfo(this); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/builder/BinaryXmlFileInfoBuilder.java ================================================ package software.coley.recaf.info.builder; import jakarta.annotation.Nonnull; import software.coley.recaf.info.BasicBinaryXmlFileInfo; import software.coley.recaf.info.BinaryXmlFileInfo; /** * Builder for {@link BasicBinaryXmlFileInfo}. * * @author Matt Coley */ public class BinaryXmlFileInfoBuilder extends ChunkFileInfoBuilder { public BinaryXmlFileInfoBuilder() { // empty } public BinaryXmlFileInfoBuilder(BinaryXmlFileInfo xmlInfo) { super(xmlInfo); } @Nonnull @Override public BasicBinaryXmlFileInfo build() { return new BasicBinaryXmlFileInfo(this); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/builder/ChunkFileInfoBuilder.java ================================================ package software.coley.recaf.info.builder; import software.coley.recaf.info.AndroidChunkFileInfo; /** * Common builder for {@link ArscFileInfoBuilder} and {@link BinaryXmlFileInfoBuilder}. * * @param * Self type. Exists so implementations don't get stunted in their chaining. * * @author Matt Coley */ public abstract class ChunkFileInfoBuilder> extends FileInfoBuilder { public ChunkFileInfoBuilder() { // empty } public ChunkFileInfoBuilder(AndroidChunkFileInfo chunkInfo) { super(chunkInfo); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/builder/DexFileInfoBuilder.java ================================================ package software.coley.recaf.info.builder; import jakarta.annotation.Nonnull; import software.coley.recaf.info.BasicDexFileInfo; import software.coley.recaf.info.DexFileInfo; /** * Builder for {@link DexFileInfo}. * * @author Matt Coley */ public class DexFileInfoBuilder extends FileInfoBuilder { public DexFileInfoBuilder() { // empty } public DexFileInfoBuilder(DexFileInfo dexFileInfo) { super(dexFileInfo); } public DexFileInfoBuilder(FileInfoBuilder other) { super(other); } @Nonnull @Override public BasicDexFileInfo build() { return new BasicDexFileInfo(this); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/builder/FileInfoBuilder.java ================================================ package software.coley.recaf.info.builder; import jakarta.annotation.Nonnull; import software.coley.recaf.info.*; import software.coley.recaf.info.properties.BasicPropertyContainer; import software.coley.recaf.info.properties.Property; import software.coley.recaf.info.properties.PropertyContainer; import software.coley.recaf.util.StringDecodingResult; import software.coley.recaf.util.StringUtil; /** * Common builder info for {@link FileInfo}. * * @param * Self type. Exists so implementations don't get stunted in their chaining. * * @author Matt Coley */ public class FileInfoBuilder> { private PropertyContainer properties = new BasicPropertyContainer(); private String name; private byte[] rawContent; protected StringDecodingResult decodingResult; public FileInfoBuilder() { // default } protected FileInfoBuilder(@Nonnull FileInfo fileInfo) { // copy state withName(fileInfo.getName()); withRawContent(fileInfo.getRawContent()); withProperties(new BasicPropertyContainer(fileInfo.getProperties())); } protected FileInfoBuilder(@Nonnull FileInfoBuilder other) { withName(other.getName()); withRawContent(other.getRawContent()); withProperties(other.getProperties()); } @Nonnull @SuppressWarnings("unchecked") public static > B forFile(FileInfo info) { FileInfoBuilder builder; if (info.isZipFile()) { // Handle different container types if (info instanceof JarFileInfo jarInfo) { builder = new JarFileInfoBuilder(jarInfo); } else if (info instanceof JModFileInfo modInfo) { builder = new JModFileInfoBuilder(modInfo); } else if (info instanceof WarFileInfo warInfo) { builder = new WarFileInfoBuilder(warInfo); } else { builder = new ZipFileInfoBuilder(info.asZipFile()); } } else if (info instanceof DexFileInfo dexInfo) { builder = new DexFileInfoBuilder(dexInfo); } else if (info instanceof ModulesFileInfo modInfo) { builder = new ModulesFileInfoBuilder(modInfo); } else if (info instanceof TextFileInfo textInfo) { builder = new TextFileInfoBuilder(textInfo); } else if (info instanceof BinaryXmlFileInfo xmlInfo) { builder = new BinaryXmlFileInfoBuilder(xmlInfo); } else if (info instanceof ArscFileInfo arscInfo) { builder = new ArscFileInfoBuilder(arscInfo); } else { builder = new FileInfoBuilder<>(info); } return (B) builder; } @SuppressWarnings("unchecked") public B withProperties(@Nonnull PropertyContainer properties) { this.properties = properties; return (B) this; } @SuppressWarnings("unchecked") public B withProperty(@Nonnull Property property) { properties.setProperty(property); return (B) this; } @SuppressWarnings("unchecked") public B withName(@Nonnull String name) { this.name = name; return (B) this; } @SuppressWarnings("unchecked") public B withRawContent(@Nonnull byte[] rawContent) { this.rawContent = rawContent; decodingResult = null; // Clear decoding when content changes return (B) this; } public PropertyContainer getProperties() { return properties; } public String getName() { return name; } public byte[] getRawContent() { return rawContent; } /** * @return Computed string decoding result. */ @Nonnull protected StringDecodingResult getDecodingResult() { if (decodingResult == null) decodingResult = StringUtil.decodeString(rawContent); return decodingResult; } @Nonnull public BasicFileInfo build() { if (name == null) throw new IllegalArgumentException("Name is required"); if (rawContent == null) throw new IllegalArgumentException("Content is required"); if (getDecodingResult().couldDecode()) return new TextFileInfoBuilder(this, getDecodingResult()).build(); else return new BasicFileInfo(this); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/builder/ImageFileInfoBuilder.java ================================================ package software.coley.recaf.info.builder; import jakarta.annotation.Nonnull; import software.coley.recaf.info.BasicImageFileInfo; import software.coley.recaf.info.ImageFileInfo; /** * Builder for {@link ImageFileInfo}. * * @author Matt Coley */ public class ImageFileInfoBuilder extends FileInfoBuilder { public ImageFileInfoBuilder() { // empty } public ImageFileInfoBuilder(ImageFileInfo imageFileInfo) { super(imageFileInfo); } public ImageFileInfoBuilder(FileInfoBuilder other) { super(other); } @Nonnull @Override public BasicImageFileInfo build() { return new BasicImageFileInfo(this); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/builder/JModFileInfoBuilder.java ================================================ package software.coley.recaf.info.builder; import jakarta.annotation.Nonnull; import software.coley.recaf.info.*; /** * Builder for {@link JModFileInfo}. * * @author Matt Coley */ public class JModFileInfoBuilder extends ZipFileInfoBuilder { public JModFileInfoBuilder() { // empty } public JModFileInfoBuilder(@Nonnull JModFileInfo jmodInfo) { super(jmodInfo); } public JModFileInfoBuilder(@Nonnull FileInfoBuilder other) { super(other); } @Nonnull @Override public BasicJModFileInfo build() { return new BasicJModFileInfo(this); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/builder/JarFileInfoBuilder.java ================================================ package software.coley.recaf.info.builder; import jakarta.annotation.Nonnull; import software.coley.recaf.info.BasicJarFileInfo; import software.coley.recaf.info.JarFileInfo; /** * Builder for {@link JarFileInfo}. * * @author Matt Coley */ public class JarFileInfoBuilder extends ZipFileInfoBuilder { public JarFileInfoBuilder() { // empty } public JarFileInfoBuilder(@Nonnull JarFileInfo jarInfo) { super(jarInfo); } public JarFileInfoBuilder(@Nonnull ZipFileInfoBuilder other) { super(other); } @Nonnull @Override public BasicJarFileInfo build() { return new BasicJarFileInfo(this); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/builder/JvmClassInfoBuilder.java ================================================ package software.coley.recaf.info.builder; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.objectweb.asm.AnnotationVisitor; import org.objectweb.asm.Attribute; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.ClassWriter; import org.objectweb.asm.FieldVisitor; import org.objectweb.asm.Label; import org.objectweb.asm.MethodVisitor; import org.objectweb.asm.RecordComponentVisitor; import org.objectweb.asm.Type; import org.objectweb.asm.TypePath; import software.coley.cafedude.classfile.attribute.AttributeContexts; import software.coley.cafedude.io.AttributeHolderType; import software.coley.recaf.info.BasicInnerClassInfo; import software.coley.recaf.info.BasicJvmClassInfo; import software.coley.recaf.info.InnerClassInfo; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.annotation.AnnotationElement; import software.coley.recaf.info.annotation.AnnotationInfo; import software.coley.recaf.info.annotation.BasicAnnotationElement; import software.coley.recaf.info.annotation.BasicAnnotationEnumReference; import software.coley.recaf.info.annotation.BasicAnnotationInfo; import software.coley.recaf.info.annotation.TypeAnnotationInfo; import software.coley.recaf.info.member.BasicFieldMember; import software.coley.recaf.info.member.BasicLocalVariable; import software.coley.recaf.info.member.BasicMethodMember; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.info.properties.builtin.UnknownAttributesProperty; import software.coley.recaf.util.MultiMap; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.EnumSet; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeSet; import java.util.function.Consumer; import static software.coley.recaf.RecafConstants.getAsmVersion; /** * Builder for {@link JvmClassInfo}. * * @author Matt Coley */ public class JvmClassInfoBuilder extends AbstractClassInfoBuilder { private byte[] bytecode; private int version = JvmClassInfo.BASE_VERSION + 8; // Java 8 private boolean skipValidationChecks = true; @Nullable private ClassBuilderAdapter adapter; /** * Create empty builder. */ public JvmClassInfoBuilder() { super(); } /** * Create a builder with data pulled from the given class. * * @param classInfo * Class to pull data from. */ public JvmClassInfoBuilder(@Nonnull JvmClassInfo classInfo) { super(classInfo); withBytecode(classInfo.getBytecode()); withVersion(classInfo.getVersion()); } /** * Creates a builder with data pulled from the given bytecode. * * @param reader * ASM class reader to read bytecode from. */ public JvmClassInfoBuilder(@Nonnull ClassReader reader) { adaptFrom(reader); } /** * Creates a builder with data pulled from the given bytecode. * * @param reader * ASM class reader to read bytecode from. * @param readerFlags * Reader flags to use when populating information via {@link ClassReader#accept(ClassVisitor, int)}. */ public JvmClassInfoBuilder(@Nonnull ClassReader reader, int readerFlags) { adaptFrom(reader, readerFlags); } /** * Creates a builder with the given bytecode. * * @param bytecode * Class bytecode to read values from. */ public JvmClassInfoBuilder(@Nonnull byte[] bytecode) { this(bytecode, 0); } /** * Creates a builder with the given bytecode. * * @param bytecode * Class bytecode to read values from. * @param readerFlags * Reader flags to use when populating information via {@link ClassReader#accept(ClassVisitor, int)}. */ public JvmClassInfoBuilder(@Nonnull byte[] bytecode, int readerFlags) { adaptFrom(bytecode, readerFlags); } /** * Copies over values by reading the contents of the class file in the reader. * Calls {@link #adaptFrom(byte[], int)} with {@code flags=0}. *

* IMPORTANT: If {@link #skipValidationChecks(boolean)} is {@code false} and validation checks are active * extra steps are taken to ensure the class is fully ASM compliant. You will want to wrap this call in a try-catch * block handling {@link Throwable} to cover any potential ASM failure. *
* Validation is disabled by default. *
* If you wish to validate the input, you must use the one of the given constructors: *

    *
  • {@link #JvmClassInfoBuilder()}
  • *
  • {@link #JvmClassInfoBuilder(JvmClassInfo)}
  • *
* * @param code * Class bytecode to pull data from. * * @return Builder. */ @Nonnull public JvmClassInfoBuilder adaptFrom(@Nonnull byte[] code) { return adaptFrom(code, 0); } /** * Copies over values by reading the contents of the class file in the reader. * Calls {@link #adaptFrom(ClassReader, int)} with {@code flags}. *

* IMPORTANT: If {@link #skipValidationChecks(boolean)} is {@code false} and validation checks are active * extra steps are taken to ensure the class is fully ASM compliant. You will want to wrap this call in a try-catch * block handling {@link Throwable} to cover any potential ASM failure. *
* Validation is disabled by default. *
* If you wish to validate the input, you must use the one of the given constructors: *

    *
  • {@link #JvmClassInfoBuilder()}
  • *
  • {@link #JvmClassInfoBuilder(JvmClassInfo)}
  • *
* * @param code * Class bytecode to pull data from. * @param readerFlags * Reader flags to use when populating information via {@link ClassReader#accept(ClassVisitor, int)}. * * @return Builder. */ @Nonnull public JvmClassInfoBuilder adaptFrom(@Nonnull byte[] code, int readerFlags) { return adaptFrom(new ClassReader(code), readerFlags); } /** * Copies over values by reading the contents of the class file in the reader. * Calls {@link #adaptFrom(ClassReader, int)} with {@code flags=0}. *

* IMPORTANT: If {@link #skipValidationChecks(boolean)} is {@code false} and validation checks are active * extra steps are taken to ensure the class is fully ASM compliant. You will want to wrap this call in a try-catch * block handling {@link Throwable} to cover any potential ASM failure. *
* Validation is disabled by default. *
* If you wish to validate the input, you must use the one of the given constructors: *

    *
  • {@link #JvmClassInfoBuilder()}
  • *
  • {@link #JvmClassInfoBuilder(JvmClassInfo)}
  • *
* * @param reader * ASM class reader to pull data from. * * @return Builder. */ @Nonnull public JvmClassInfoBuilder adaptFrom(@Nonnull ClassReader reader) { return adaptFrom(reader, 0); } /** * Copies over values by reading the contents of the class file in the reader. *

* IMPORTANT: If {@link #skipValidationChecks(boolean)} is {@code false} and validation checks are active * extra steps are taken to ensure the class is fully ASM compliant. You will want to wrap this call in a try-catch * block handling {@link Throwable} to cover any potential ASM failure. *
* Validation is disabled by default. *
* If you wish to validate the input, you must use the one of the given constructors: *

    *
  • {@link #JvmClassInfoBuilder()}
  • *
  • {@link #JvmClassInfoBuilder(JvmClassInfo)}
  • *
* * @param reader * ASM class reader to pull data from. * @param flags * Reader flags to use when populating information. * * @return Builder. */ @Nonnull @SuppressWarnings(value = "deprecation") public JvmClassInfoBuilder adaptFrom(@Nonnull ClassReader reader, int flags) { // If we are doing validation checks, delegating the reader to a writer should catch most issues // that would normally crash ASM. It is the caller's responsibility to error handle ASM failing // if such failures occur. if (skipValidationChecks) { adapter = new ClassBuilderAdapter(null); reader.accept(adapter, flags); } else { ClassWriter cw = new ClassWriter(reader, 0); adapter = new ClassBuilderAdapter(cw); reader.accept(adapter, flags); cw.toByteArray(); } return withBytecode(reader.b); } @Nonnull public JvmClassInfoBuilder withBytecode(byte[] bytecode) { this.bytecode = bytecode; return this; } @Nonnull public JvmClassInfoBuilder withVersion(int version) { this.version = version; return this; } /** * The default value is {@code true}. Setting to {@code false} enables class validation steps. * When {@link #verify()} is run it will check if there are any custom attributes. * * @param skipValidationChecks * {@code false} if we want to verify the classes custom attribute * * @return {@code JvmClassInfoBuilder} */ @Nonnull public JvmClassInfoBuilder skipValidationChecks(boolean skipValidationChecks) { this.skipValidationChecks = skipValidationChecks; return this; } public byte[] getBytecode() { return bytecode; } public int getVersion() { return version; } @Override public JvmClassInfo build() { if (adapter != null && adapter.hasCustomAttributes()) getPropertyContainer().setProperty(new UnknownAttributesProperty(adapter.getCustomAttributeNames())); verify(); return new BasicJvmClassInfo(this); } @Override protected void verify() { super.verify(); if (bytecode == null) throw new IllegalStateException("Bytecode required"); if (version < JvmClassInfo.BASE_VERSION) throw new IllegalStateException("Version cannot be lower than 44 (v1)"); } /** * Converts ASM visitor actions to 'with' actions in the class builder. * Results in a fully reconstructed class model. * * @see FieldBuilderAdapter * @see MethodBuilderAdapter */ private class ClassBuilderAdapter extends ClassVisitor { private List annotations; private List typeAnnotations; private List innerClasses; private List fields; private List methods; private List classCustomAttributes; private final MultiMap> fieldCustomAttributes; private final MultiMap> methodCustomAttributes; protected ClassBuilderAdapter(@Nullable ClassVisitor cv) { super(getAsmVersion(), cv); fieldCustomAttributes = MultiMap.from(new HashMap<>(), ArrayList::new); methodCustomAttributes = MultiMap.from(new HashMap<>(), ArrayList::new); } @Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { super.visit(version, access, name, signature, superName, interfaces); if (name == null) // If you encounter this, you probably generated the class with ClassWriter, but forgot to actually // pass the ClassWriter as a delegate to the ClassVisitor manipulating the class. Thus, it is never // informed of the actual contents of the class. throw new IllegalStateException("Invalid class, name is null"); withVersion(version & 0xFF); withAccess(access); withName(name); withSignature(signature); withSuperName(superName); withInterfaces(Arrays.asList(interfaces)); } @Override public void visitSource(String source, String debug) { super.visitSource(source, debug); withSourceFileName(source); } @Override public void visitOuterClass(String owner, String name, String descriptor) { super.visitOuterClass(owner, name, descriptor); withOuterClassName(owner); withOuterMethodName(name); withOuterMethodDescriptor(descriptor); } @Override public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) { if (annotations == null) annotations = new ArrayList<>(); return new AnnotationBuilderAdapter(visible, descriptor, annotations::add); } @Override public AnnotationVisitor visitTypeAnnotation(int typeRef, TypePath typePath, String descriptor, boolean visible) { if (typeAnnotations == null) typeAnnotations = new ArrayList<>(); return new AnnotationBuilderAdapter(visible, descriptor, anno -> typeAnnotations.add(anno.withTypeInfo(typeRef, typePath))); } @Override public void visitInnerClass(String name, String outerName, String innerName, int access) { super.visitInnerClass(name, outerName, innerName, access); // Get the name of the class being visited String currentClassName = getName(); // Add the inner data if (innerClasses == null) innerClasses = new ArrayList<>(); innerClasses.add(new BasicInnerClassInfo(currentClassName, name, outerName, innerName, access)); // If the local 'name' is the current class name, then we are visiting an inner class entry // that most likely is a representation of the current class. If this entry has data about // the outer class, we want to grab it. if (name.equals(currentClassName)) { // Only need to do this once, and some entries may not have data. // Because they can be in any order we need to protect against re-assigning null. if (getOuterClassName() == null && outerName != null && currentClassName.startsWith(outerName)) { withOuterClassName(outerName); } } } @Override public void visitAttribute(Attribute attribute) { validateAttribute(attribute, AttributeHolderType.CLASS); if (classCustomAttributes == null) classCustomAttributes = new ArrayList<>(); classCustomAttributes.add(attribute); super.visitAttribute(attribute); } private void validateAttribute(@Nonnull Attribute attribute, @Nonnull AttributeHolderType context) { if (!skipValidationChecks) { // Check if this is a known attribute (allowed context is not empty) // and if it is, check if it is allowed on the given context. EnumSet allowedContexts = AttributeContexts.getAllowedContexts(attribute.type); if (!allowedContexts.isEmpty() && !allowedContexts.contains(context)) throw new IllegalStateException("Invalid" + context.name().toLowerCase() + " attribute: " + attribute.type); } } @Override public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) { FieldVisitor fv = super.visitField(access, name, descriptor, signature, value); return new FieldBuilderAdapter(fv, access, name, descriptor, signature, value) { @Override public void visitAttribute(Attribute attribute) { validateAttribute(attribute, AttributeHolderType.FIELD); fieldCustomAttributes.get(name).add(attribute); super.visitAttribute(attribute); } @Override public void visitEnd() { if (fields == null) fields = new ArrayList<>(); fields.add(getFieldMember()); super.visitEnd(); } }; } @Override public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions); return new MethodBuilderAdapter(mv, access, name, descriptor, signature, exceptions) { @Override public void visitAttribute(Attribute attribute) { validateAttribute(attribute, AttributeHolderType.METHOD); methodCustomAttributes.get(name).add(attribute); super.visitAttribute(attribute); } @Override public void visitEnd() { super.visitEnd(); if (methods == null) methods = new ArrayList<>(); methods.add(getMethodMember()); } }; } @Override public RecordComponentVisitor visitRecordComponent(String name, String descriptor, String signature) { RecordComponentVisitor rcv = super.visitRecordComponent(name, descriptor, signature); return new RecordComponentVisitor(getAsmVersion(), rcv) { @Override public void visitAttribute(Attribute attribute) { validateAttribute(attribute, AttributeHolderType.RECORD_COMPONENT); super.visitAttribute(attribute); } }; } @Override public void visitEnd() { super.visitEnd(); if (fields == null) fields = Collections.emptyList(); if (methods == null) methods = Collections.emptyList(); if (innerClasses == null) innerClasses = Collections.emptyList(); if (annotations == null) annotations = Collections.emptyList(); if (typeAnnotations == null) typeAnnotations = Collections.emptyList(); withAnnotations(annotations); withTypeAnnotations(typeAnnotations); withFields(fields); withMethods(methods); withInnerClasses(innerClasses); } /** * @return {@code true} when any custom attributes were found. */ public boolean hasCustomAttributes() { return (classCustomAttributes != null && !classCustomAttributes.isEmpty()) || (!fieldCustomAttributes.isEmpty()) || !methodCustomAttributes.isEmpty(); } /** * @return Unique names of attributes found. */ @Nonnull public Collection getCustomAttributeNames() { Set names = new TreeSet<>(); if (classCustomAttributes != null) classCustomAttributes.stream() .map(a -> a.type) .forEach(names::add); fieldCustomAttributes.values() .map(a -> a.type) .forEach(names::add); methodCustomAttributes.values() .map(a -> a.type) .forEach(names::add); return names; } } private static class FieldBuilderAdapter extends FieldVisitor { private final BasicFieldMember fieldMember; public FieldBuilderAdapter(FieldVisitor fv, int access, String name, String descriptor, String signature, Object value) { super(getAsmVersion(), fv); fieldMember = new BasicFieldMember(name, descriptor, signature, access, value); } @Override public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) { return new AnnotationBuilderAdapter(visible, descriptor, fieldMember::addAnnotation); } @Override public AnnotationVisitor visitTypeAnnotation(int typeRef, TypePath typePath, String descriptor, boolean visible) { return new AnnotationBuilderAdapter(visible, descriptor, anno -> fieldMember.addTypeAnnotation(anno.withTypeInfo(typeRef, typePath))); } @Nonnull public BasicFieldMember getFieldMember() { return fieldMember; } } private static class MethodBuilderAdapter extends MethodVisitor { private final BasicMethodMember methodMember; private final Type methodDescriptor; private List parameters; private int parameterIndex; private int parameterSlot; public MethodBuilderAdapter(MethodVisitor mv, int access, String name, String descriptor, String signature, String[] exceptions) { super(getAsmVersion(), mv); List exceptionList = exceptions == null ? Collections.emptyList() : Arrays.asList(exceptions); methodMember = new BasicMethodMember(name, descriptor, signature, access, exceptionList); methodDescriptor = Type.getMethodType(descriptor); parameterSlot = methodMember.hasStaticModifier() ? 0 : 1; } @Override public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) { return new AnnotationBuilderAdapter(visible, descriptor, methodMember::addAnnotation); } @Override public AnnotationVisitor visitTypeAnnotation(int typeRef, TypePath typePath, String descriptor, boolean visible) { return new AnnotationBuilderAdapter(visible, descriptor, anno -> methodMember.addTypeAnnotation(anno.withTypeInfo(typeRef, typePath))); } @Override public void visitLocalVariable(String name, String descriptor, String signature, Label start, Label end, int index) { if (name != null && descriptor != null) methodMember.addLocalVariable(new BasicLocalVariable(index, name, descriptor, signature)); super.visitLocalVariable(name, descriptor, signature, start, end, index); } @Override public void visitParameter(String name, int access) { super.visitParameter(name, access); Type[] argumentTypes = methodDescriptor.getArgumentTypes(); if (parameterIndex < argumentTypes.length) { Type argumentType = argumentTypes[parameterIndex]; // Only add when we have a name for the parameter. if (name != null) { if (parameters == null) parameters = new ArrayList<>(methodDescriptor.getArgumentCount()); parameters.add(new BasicLocalVariable(parameterSlot, name, argumentType.getDescriptor(), null)); } parameterIndex++; parameterSlot += argumentType.getSize(); } } @Override public AnnotationVisitor visitAnnotationDefault() { return new DefaultAnnotationAdapter(anno -> { AnnotationElement element = anno.getElements().get(DefaultAnnotationAdapter.KEY); if (element != null) methodMember.setAnnotationDefault(element); }); } @Override public void visitEnd() { super.visitEnd(); // Add local variables generated from the visited parameters if the local variable table hasn't already // provided variables for those indices. This assists in providing variable models for abstract methods. // This only works when a 'MethodParameters' attribute is present on the method. The javac compiler // emits this when passing '-parameters'. if (parameters != null) for (LocalVariable parameter : parameters) if (methodMember.getLocalVariable(parameter.getIndex()) == null) methodMember.addLocalVariable(parameter); } @Nonnull public BasicMethodMember getMethodMember() { return methodMember; } } private static class AnnotationBuilderAdapter extends AnnotationVisitor { private final Consumer annotationConsumer; protected final Map elements = new HashMap<>(); private final List arrayValues = new ArrayList<>(); private final List subAnnotations = new ArrayList<>(); private final boolean visible; private final String descriptor; protected AnnotationBuilderAdapter(boolean visible, String descriptor, Consumer annotationConsumer) { super(getAsmVersion()); this.visible = visible; this.descriptor = descriptor; this.annotationConsumer = annotationConsumer; } @Override public void visit(String name, Object value) { super.visit(name, value); // The 'value' can technically be a primitive array, but it doesn't really matter // how we capture it. if (name == null) { arrayValues.add(value); } else { elements.put(name, new BasicAnnotationElement(name, value)); } } @Override public void visitEnum(String name, String descriptor, String value) { super.visitEnum(name, descriptor, value); BasicAnnotationEnumReference enumRef = new BasicAnnotationEnumReference(descriptor, value); if (name == null) { arrayValues.add(enumRef); } else { elements.put(name, new BasicAnnotationElement(name, enumRef)); } } @Override public AnnotationVisitor visitAnnotation(String name, String descriptor) { return new AnnotationBuilderAdapter(true, descriptor, anno -> { if (name == null) { AnnotationBuilderAdapter.this.arrayValues.add(anno); } else { AnnotationBuilderAdapter.this.elements.put(name, new BasicAnnotationElement(name, anno)); } }) { @Override protected void populate(@Nonnull BasicAnnotationInfo anno) { super.populate(anno); AnnotationBuilderAdapter.this.subAnnotations.add(anno); } }; } @Override public AnnotationVisitor visitArray(String name) { AnnotationBuilderAdapter outer = this; return new AnnotationBuilderAdapter(true, "", null) { @Override public void visitEnd() { AnnotationBuilderAdapter inner = this; outer.elements.put(name, new BasicAnnotationElement(name, inner.arrayValues)); } }; } @Override public void visitEnd() { super.visitEnd(); populate(new BasicAnnotationInfo(visible, descriptor)); } protected void populate(@Nonnull BasicAnnotationInfo anno) { elements.forEach((name, value) -> anno.addElement(value)); subAnnotations.forEach(anno::addAnnotation); if (annotationConsumer != null) annotationConsumer.accept(anno); } } private static class DefaultAnnotationAdapter extends AnnotationBuilderAdapter { private static final String KEY = "value"; protected DefaultAnnotationAdapter(Consumer annotationConsumer) { super(true, "Ljava/lang/Object;", annotationConsumer); } @Override public void visitEnum(String name, String descriptor, String value) { name = KEY; BasicAnnotationEnumReference enumRef = new BasicAnnotationEnumReference(descriptor, value); elements.put(name, new BasicAnnotationElement(name, enumRef)); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/builder/ModulesFileInfoBuilder.java ================================================ package software.coley.recaf.info.builder; import jakarta.annotation.Nonnull; import software.coley.recaf.info.BasicModulesFileInfo; import software.coley.recaf.info.ModulesFileInfo; /** * Builder for {@link ModulesFileInfo}. * * @author Matt Coley */ public class ModulesFileInfoBuilder extends FileInfoBuilder { public ModulesFileInfoBuilder() { // empty } public ModulesFileInfoBuilder(ModulesFileInfo modulesFileInfo) { super(modulesFileInfo); } public ModulesFileInfoBuilder(FileInfoBuilder other) { super(other); } @Nonnull @Override public BasicModulesFileInfo build() { return new BasicModulesFileInfo(this); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/builder/NativeLibraryFileInfoBuilder.java ================================================ package software.coley.recaf.info.builder; import jakarta.annotation.Nonnull; import software.coley.recaf.info.BasicNativeLibraryFileInfo; import software.coley.recaf.info.NativeLibraryFileInfo; /** * Builder for {@link NativeLibraryFileInfo}. * * @author Matt Coley */ public class NativeLibraryFileInfoBuilder extends FileInfoBuilder { public NativeLibraryFileInfoBuilder() { // empty } public NativeLibraryFileInfoBuilder(NativeLibraryFileInfo libraryFileInfo) { super(libraryFileInfo); } public NativeLibraryFileInfoBuilder(FileInfoBuilder other) { super(other); } @Nonnull @Override public BasicNativeLibraryFileInfo build() { return new BasicNativeLibraryFileInfo(this); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/builder/TextFileInfoBuilder.java ================================================ package software.coley.recaf.info.builder; import jakarta.annotation.Nonnull; import software.coley.recaf.info.BasicTextFileInfo; import software.coley.recaf.info.TextFileInfo; import software.coley.recaf.util.StringDecodingResult; import java.nio.charset.Charset; import java.util.Objects; /** * Builder for {@link TextFileInfo}. * * @author Matt Coley */ public class TextFileInfoBuilder extends FileInfoBuilder { public TextFileInfoBuilder() { // empty } public TextFileInfoBuilder(@Nonnull TextFileInfo textInfo) { super(textInfo); this.decodingResult = new StringDecodingResult(textInfo.getRawContent(), textInfo.getCharset(), textInfo.getText()); } public TextFileInfoBuilder(@Nonnull FileInfoBuilder other, @Nonnull StringDecodingResult decodingResult) { super(other); this.decodingResult = decodingResult; } @Nonnull public TextFileInfoBuilder withText(@Nonnull String text) { return withRawContent(text.getBytes(getCharset())); } @Nonnull public String getText() { return Objects.requireNonNull(getDecodingResult().text(), "File '" + getName() + "' could not be decoded"); } @Nonnull public Charset getCharset() { return Objects.requireNonNull(getDecodingResult().charset(), "File '" + getName() + "' could not be decoded"); } @Nonnull @Override public BasicTextFileInfo build() { return new BasicTextFileInfo(this); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/builder/VideoFileInfoBuilder.java ================================================ package software.coley.recaf.info.builder; import jakarta.annotation.Nonnull; import software.coley.recaf.info.BasicVideoFileInfo; import software.coley.recaf.info.VideoFileInfo; /** * Builder for {@link VideoFileInfo}. * * @author Matt Coley */ public class VideoFileInfoBuilder extends FileInfoBuilder { public VideoFileInfoBuilder() { // empty } public VideoFileInfoBuilder(VideoFileInfo videoFileInfo) { super(videoFileInfo); } public VideoFileInfoBuilder(FileInfoBuilder other) { super(other); } @Nonnull @Override public BasicVideoFileInfo build() { return new BasicVideoFileInfo(this); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/builder/WarFileInfoBuilder.java ================================================ package software.coley.recaf.info.builder; import jakarta.annotation.Nonnull; import software.coley.recaf.info.*; /** * Builder for {@link WarFileInfo}. * * @author Matt Coley */ public class WarFileInfoBuilder extends ZipFileInfoBuilder { public WarFileInfoBuilder() { // empty } public WarFileInfoBuilder(@Nonnull WarFileInfo warInfo) { super(warInfo); } public WarFileInfoBuilder(@Nonnull FileInfoBuilder other) { super(other); } @Nonnull @Override public BasicWarFileInfo build() { return new BasicWarFileInfo(this); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/builder/ZipFileInfoBuilder.java ================================================ package software.coley.recaf.info.builder; import jakarta.annotation.Nonnull; import software.coley.recaf.info.BasicZipFileInfo; import software.coley.recaf.info.ZipFileInfo; /** * Builder for {@link ZipFileInfo}. * * @author Matt Coley * @see JarFileInfoBuilder * @see JModFileInfoBuilder * @see WarFileInfoBuilder * @see ApkFileInfoBuilder */ public class ZipFileInfoBuilder extends FileInfoBuilder { public ZipFileInfoBuilder() { // empty } public ZipFileInfoBuilder(@Nonnull ZipFileInfo zipInfo) { super(zipInfo); } public ZipFileInfoBuilder(@Nonnull FileInfoBuilder other) { super(other); } @Nonnull public JarFileInfoBuilder asJar() { return new JarFileInfoBuilder(this); } @Nonnull public ApkFileInfoBuilder asApk() { return new ApkFileInfoBuilder(this); } @Nonnull public JModFileInfoBuilder asJMod() { return new JModFileInfoBuilder(this); } @Nonnull public WarFileInfoBuilder asWar() { return new WarFileInfoBuilder(this); } @Nonnull @Override public BasicZipFileInfo build() { return new BasicZipFileInfo(this); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/member/BasicFieldMember.java ================================================ package software.coley.recaf.info.member; import java.util.Objects; /** * Basic implementation of a field member. * * @author Matt Coley */ public class BasicFieldMember extends BasicMember implements FieldMember { private final Object defaultValue; /** * @param name * Field name. * @param desc * Field descriptor. * @param signature * Field generic signature. May be {@code null}. * @param access * Field access modifiers. * @param defaultValue * Default value. May be {@code null}. */ public BasicFieldMember(String name, String desc, String signature, int access, Object defaultValue) { super(name, desc, signature, access); this.defaultValue = defaultValue; } @Override public Object getDefaultValue() { return defaultValue; } @Override public String toString() { return "Field: " + getDescriptor() + " " + getName(); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || !FieldMember.class.isAssignableFrom(o.getClass())) return false; FieldMember field = (FieldMember) o; if (!getName().equals(field.getName())) return false; if (!Objects.equals(getSignature(), field.getSignature())) return false; if (!Objects.equals(getDefaultValue(), field.getDefaultValue())) return false; return getDescriptor().equals(field.getDescriptor()); } @Override public int hashCode() { int result = getName().hashCode(); result = 31 * result + getDescriptor().hashCode(); if (getSignature() != null) result = 31 * result + getSignature().hashCode(); if (getDefaultValue() != null) result = 31 * result + getDefaultValue().hashCode(); return result; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/member/BasicLocalVariable.java ================================================ package software.coley.recaf.info.member; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import java.util.Objects; /** * Basic implementation of a local variable. * * @author Matt Coley */ public class BasicLocalVariable implements LocalVariable { private final int index; private final String name; private final String desc; private final String signature; /** * @param index * Variable index. * @param name * Variable name. * @param desc * Variable type. * @param signature * Variable generic type. */ public BasicLocalVariable(int index, @Nonnull String name, @Nonnull String desc, @Nullable String signature) { this.index = index; this.name = name; this.desc = desc; this.signature = signature; } @Override public int getIndex() { return index; } @Nonnull @Override public String getName() { return name; } @Nonnull @Override public String getDescriptor() { return desc; } @Nullable @Override public String getSignature() { return signature; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; BasicLocalVariable that = (BasicLocalVariable) o; if (index != that.index) return false; if (!name.equals(that.name)) return false; if (!desc.equals(that.desc)) return false; return Objects.equals(signature, that.signature); } @Override public int hashCode() { int result = index; result = 31 * result + name.hashCode(); result = 31 * result + desc.hashCode(); result = 31 * result + (signature != null ? signature.hashCode() : 0); return result; } @Override public String toString() { return index + ": " + name; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/member/BasicMember.java ================================================ package software.coley.recaf.info.member; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.annotation.AnnotationInfo; import software.coley.recaf.info.annotation.TypeAnnotationInfo; import software.coley.recaf.info.properties.BasicPropertyContainer; import software.coley.recaf.info.properties.Property; import software.coley.recaf.info.properties.PropertyContainer; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; /** * Common base for basic member implementations. * * @author Matt Coley */ public abstract class BasicMember implements ClassMember { private final String name; private final String desc; private final String signature; private final int access; private PropertyContainer properties; private List annotations; private List typeAnnotations; private ClassInfo declaringClass; protected BasicMember(@Nonnull String name, @Nonnull String desc, @Nullable String signature, int access) { this.name = name; this.desc = desc; this.signature = signature; this.access = access; } /** * For internal use when populating the model. * Adding an annotation here does not change the bytecode of the class. * * @param annotation * Annotation to add. */ public void addAnnotation(@Nonnull AnnotationInfo annotation) { if (annotations == null) annotations = new ArrayList<>(2); annotations.add(annotation); } /** * For internal use when populating the model. * Adding an annotation here does not change the bytecode of the class. * * @param typeAnnotation * Annotation to add. */ public void addTypeAnnotation(@Nonnull TypeAnnotationInfo typeAnnotation) { if (typeAnnotations == null) typeAnnotations = new ArrayList<>(2); typeAnnotations.add(typeAnnotation); } /** * For internal use when populating the model. * * @param declaringClass * Declaring class to assign. */ public void setDeclaringClass(@Nonnull ClassInfo declaringClass) { this.declaringClass = declaringClass; } @Override public ClassInfo getDeclaringClass() { return declaringClass; } @Override public int getAccess() { return access; } @Nonnull @Override public String getName() { return name; } @Nonnull @Override public String getDescriptor() { return desc; } @Override public String getSignature() { return signature; } @Nonnull @Override public List getAnnotations() { if (annotations == null) return Collections.emptyList(); return annotations; } @Nonnull @Override public List getTypeAnnotations() { if (typeAnnotations == null) return Collections.emptyList(); return typeAnnotations; } @Override public void setProperty(Property property) { if (properties == null) properties = new BasicPropertyContainer(); properties.setProperty(property); } @Override public void removeProperty(String key) { if (properties != null) properties.removeProperty(key); } @Nonnull @Override public Map> getProperties() { if (properties == null) return Collections.emptyMap(); return properties.getProperties(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/member/BasicMethodMember.java ================================================ package software.coley.recaf.info.member; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.annotation.AnnotationElement; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; /** * Basic implementation of a method member. * * @author Matt Coley */ public class BasicMethodMember extends BasicMember implements MethodMember { private final List thrownTypes; private List variables; private AnnotationElement annotationDefault; /** * @param name * Method name. * @param desc * Method descriptor. * @param signature * Method generic signature. May be {@code null}. * @param access * Method access modifiers. * @param thrownTypes * Method's thrown exceptions. */ public BasicMethodMember(@Nonnull String name, @Nonnull String desc, @Nullable String signature, int access, @Nonnull List thrownTypes) { super(name, desc, signature, access); this.thrownTypes = thrownTypes; } /** * @param variable * Variable to add. */ public void addLocalVariable(@Nonnull LocalVariable variable) { if (variables == null) variables = new ArrayList<>(); variables.add(variable); } /** * @param annotationDefault * Element value to set. */ public void setAnnotationDefault(@Nonnull AnnotationElement annotationDefault) { this.annotationDefault = annotationDefault; } @Nonnull @Override public List getThrownTypes() { return thrownTypes; } @Nonnull @Override public List getLocalVariables() { if (variables == null) return Collections.emptyList(); return variables; } @Nullable @Override public AnnotationElement getAnnotationDefault() { return annotationDefault; } @Override public String toString() { return "Method: " + getName() + getDescriptor(); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || !MethodMember.class.isAssignableFrom(o.getClass())) return false; MethodMember method = (MethodMember) o; if (!getName().equals(method.getName())) return false; if (!Objects.equals(getSignature(), method.getSignature())) return false; if (!getThrownTypes().equals(method.getThrownTypes())) return false; if (!getLocalVariables().equals(method.getLocalVariables())) return false; return getDescriptor().equals(method.getDescriptor()); } @Override public int hashCode() { int result = getName().hashCode(); result = 31 * result + getDescriptor().hashCode(); result = 31 * result + getThrownTypes().hashCode(); result = 31 * result + getLocalVariables().hashCode(); if (getSignature() != null) result = 31 * result + getSignature().hashCode(); return result; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/member/ClassMember.java ================================================ package software.coley.recaf.info.member; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.Accessed; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.Named; import software.coley.recaf.info.annotation.Annotated; import software.coley.recaf.info.properties.PropertyContainer; /** * Component of a {@link ClassInfo}. * * @author Matt Coley */ public interface ClassMember extends PropertyContainer, Annotated, Accessed, Named { /** * @return Member name. */ @Nonnull String getName(); /** * @return Member descriptor. */ @Nonnull String getDescriptor(); /** * @return Signature containing generic information. May be {@code null}. */ @Nullable String getSignature(); /** * @return The class declaring this member. * May be {@code null} if this information is not known. */ @Nullable default ClassInfo getDeclaringClass() { // Not required to be implemented. return null; } /** * @return {@code true} when the member is aware of its declaring {@link ClassInfo} */ default boolean isDeclarationAware() { return getDeclaringClass() != null; } /** * @return {@code true} when the member is a field. */ boolean isField(); /** * @return {@code true} when the member is a method. */ boolean isMethod(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/member/FieldMember.java ================================================ package software.coley.recaf.info.member; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; /** * Field component of a {@link ClassInfo}. * * @author Matt Coley */ public interface FieldMember extends ClassMember { /** * Fields can declare default values. * Only acknowledged by the JVM when {@link #hasStaticModifier()} is {@code true}. * * @return Default value of the field. Must be an {@code Integer}, a {@code Float}, a {@code Long}, * a {@code Double} or a {@code String}. May be {@code null}. */ @Nullable Object getDefaultValue(); @Override default boolean isField() { return true; } @Override default boolean isMethod() { return false; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/member/LocalVariable.java ================================================ package software.coley.recaf.info.member; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; /** * Local variable component of a {@link MethodMember}. * * @author Matt Coley */ public interface LocalVariable { /** * @return Variable index. */ int getIndex(); /** * @return Variable name. */ @Nonnull String getName(); /** * @return Variable type. */ @Nonnull String getDescriptor(); /** * @return Variable generic type. */ @Nullable String getSignature(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/member/MethodMember.java ================================================ package software.coley.recaf.info.member; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.annotation.AnnotationElement; import java.util.List; /** * Field component of a {@link ClassInfo}. * * @author Matt Coley */ public interface MethodMember extends ClassMember { /** * @return List of thrown exceptions. */ @Nonnull List getThrownTypes(); /** * @return List of local variables. */ @Nonnull List getLocalVariables(); /** * @return Element holding the default value for an annotation method. */ @Nullable AnnotationElement getAnnotationDefault(); /** * @param index * Local variable index. * * @return Local variable at the index, or {@code null} if not known. */ @Nullable default LocalVariable getLocalVariable(int index) { return getLocalVariables().stream() .filter(v -> index == v.getIndex()) .findFirst().orElse(null); } @Override default boolean isField() { return false; } @Override default boolean isMethod() { return true; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/BasicProperty.java ================================================ package software.coley.recaf.info.properties; import jakarta.annotation.Nonnull; import java.util.Objects; /** * Basic property implementation. * * @param * Value type. * * @author Matt Coley */ public class BasicProperty implements Property { private final String key; private final V value; /** * @param key * Property key. * @param value * Property value. */ public BasicProperty(String key, V value) { this.key = key; this.value = value; } @Nonnull @Override public String key() { return key; } @Override public V value() { return value; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; BasicProperty that = (BasicProperty) o; if (!Objects.equals(key, that.key)) return false; return Objects.equals(value, that.value); } @Override public int hashCode() { int result = key != null ? key.hashCode() : 0; result = 31 * result + (value != null ? value.hashCode() : 0); return result; } @Override public String toString() { return "SimpleProperty{" + "key='" + key + '\'' + ", value=" + value + '}'; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/BasicPropertyContainer.java ================================================ package software.coley.recaf.info.properties; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Objects; /** * Basic implementation of property container. * * @author Matt Coley */ public class BasicPropertyContainer implements PropertyContainer { private Map> properties; /** * Container with empty map. */ public BasicPropertyContainer() { this(null); } /** * @param properties * Pre-defined property map. */ public BasicPropertyContainer(@Nullable Map> properties) { this.properties = properties == null || properties.isEmpty() ? null : new HashMap<>(properties); } @Override public void setProperty(Property property) { if (properties == null) // Memory optimization to keep null by default properties = new HashMap<>(); properties.put(property.key(), property); } @Override public void removeProperty(String key) { if (properties != null) properties.remove(key); } @Nonnull public Map> getProperties() { if (properties == null) return Collections.emptyMap(); // Disallow modification return Collections.unmodifiableMap(properties); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; BasicPropertyContainer other = (BasicPropertyContainer) o; return Objects.equals(properties, other.properties); } @Override public int hashCode() { if (properties == null) return 0; return properties.hashCode(); } @Override public String toString() { String typeName = getClass().getSimpleName(); if (properties == null) return typeName + "[0]"; return typeName + "[" + properties.size() + " items]"; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/Property.java ================================================ package software.coley.recaf.info.properties; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; /** * Basic property outline. * * @param * Value type. * * @author Matt Coley */ public interface Property { /** * @return Property key. */ @Nonnull String key(); /** * @return Property value. */ @Nullable V value(); /** * Generally, when an info type is updated in a workspace it will be copying state from the original value. * When that occurs properties of the original info instance get copied to the new one. Not all properties should * be copied though. Consider these two cases: *
    *
  • {@link software.coley.recaf.info.properties.builtin.ZipCommentProperty} - * Is based on the input file content. Will never change based on info state, thus * should be copied between info instances.
  • *
  • {@link software.coley.recaf.info.properties.builtin.CachedDecompileProperty} - * Caches the decompiled code of a class file to prevent duplicate work. Changes when info state * (bytecode) is updated, thus should NOT be copied between info instances
  • *
* By default, this returns {@code true}. * * @return {@code true} to represent content that should be persisted across instances. * {@code false} to represent content that should be dropped between instances. */ default boolean persistent() { return true; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/PropertyContainer.java ================================================ package software.coley.recaf.info.properties; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import java.util.Map; import java.util.function.Supplier; import java.util.stream.Collectors; /** * Outline of a type with additional properties able to be assigned. * * @author Matt Coley */ public interface PropertyContainer { /** * @param key * Key of property to set. * @param value * Value of property to set. * @param * Property value type. */ default void setPropertyValue(String key, V value) { setProperty(new BasicProperty<>(key, value)); } /** * @param property * Property to set. * @param * Property value type. */ void setProperty(Property property); /** * @param key * Key of property to set. * @param property * Property to set, if no value is associated with the given key. * @param * Property value type. */ default void setPropertyIfMissing(String key, Supplier> property) { if (getProperty(key) == null) setProperty(property.get()); } /** * @param key * Key of property to remove. */ void removeProperty(String key); /** * @param key * Property key. * @param * Property value type. * * @return Property associated with key. May be {@code null} for unknown keys. */ @Nullable @SuppressWarnings("unchecked") default Property getProperty(String key) { return (Property) getProperties().get(key); } /** * @param key * Property key. * @param * Property value type. * * @return Value of property, or {@code null} if the property is not set. */ @Nullable default V getPropertyValueOrNull(String key) { Property property = getProperty(key); if (property == null) return null; return property.value(); } /** * @return Properties. */ @Nonnull Map> getProperties(); /** * @return Properties, but only those that are {@link Property#persistent()}. */ default Map> getPersistentProperties() { return getProperties().entrySet().stream() .filter((e) -> e.getValue().persistent()) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/BinaryXmlDecodedProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.BinaryXmlFileInfo; import software.coley.recaf.info.Info; import software.coley.recaf.info.properties.BasicProperty; /** * Built in property for caching the decoded contents of {@link BinaryXmlFileInfo} items. * * @author Matt Coley */ public class BinaryXmlDecodedProperty extends BasicProperty { public static final String KEY = "decoded-bin-xml"; /** * @param value * Decoded content. */ public BinaryXmlDecodedProperty(@Nonnull String value) { super(KEY, value); } /** * @param info * Info instance. * * @return Decoded XML associated with instance, or {@code null} when no association exists. */ @Nullable public static String get(@Nonnull Info info) { return info.getPropertyValueOrNull(KEY); } /** * @param info * Info instance. * @param xml * Decoded XML to associate with the item. */ public static void set(@Nonnull Info info, @Nonnull String xml) { info.setProperty(new BinaryXmlDecodedProperty(xml)); } /** * @param info * Info instance. */ public static void remove(@Nonnull Info info) { info.removeProperty(KEY); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/CachedDecompileProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.properties.BasicProperty; import software.coley.recaf.services.decompile.DecompileResult; import software.coley.recaf.services.decompile.Decompiler; import java.util.HashMap; import java.util.Map; /** * Built in property to cache decompilation results for {@link software.coley.recaf.info.ClassInfo} instances, * reducing wasted duplicate work on decompiling the same code over and over again. * * @author Matt Coley */ public class CachedDecompileProperty extends BasicProperty { public static final String KEY = "cached-decompiled-code"; /** * New empty cache. */ public CachedDecompileProperty() { super(KEY, new Cache()); } /** * @param classInfo * Class to cache decompilation of. * @param decompiler * Associated decompiler. * @param result * Decompiler result to cache. */ public static void set(@Nonnull ClassInfo classInfo, @Nonnull Decompiler decompiler, @Nonnull DecompileResult result) { Cache cache = classInfo.getPropertyValueOrNull(KEY); if (cache == null) { CachedDecompileProperty property = new CachedDecompileProperty(); classInfo.setProperty(property); cache = property.value(); } // Save to cache cache.save(decompiler.getName(), result); } /** * @param classInfo * Class with cached decompilation. * @param decompiler * Associated decompiler. * * @return Cached decompilation result, or {@code null} when no cached value exists. */ @Nullable public static DecompileResult get(@Nonnull ClassInfo classInfo, @Nonnull Decompiler decompiler) { Cache cache = classInfo.getPropertyValueOrNull(KEY); if (cache == null) return null; return cache.get(decompiler.getName()); } /** * @param info * Info instance. */ public static void remove(@Nonnull ClassInfo info) { info.removeProperty(KEY); } @Override public boolean persistent() { // We should disregard decompilation results between 'versions' of an info object. return false; } /** * Basic cache for decompiler results. */ public static class Cache { private final Map implToCode = new HashMap<>(); /** * @param decompilerId * Unique ID of decompiler. * * @return Decompiler result of prior run. */ public DecompileResult get(String decompilerId) { return implToCode.get(decompilerId); } /** * @param decompilerId * Unique ID of decompiler. * @param result * Decompiler result to cache. */ public void save(String decompilerId, DecompileResult result) { implToCode.put(decompilerId, result); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/HasMappedReferenceProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.properties.BasicProperty; /** * Built in property to track if an {@link ClassInfo} references mapped classes or members. * * @author Matt Coley * @see OriginalClassNameProperty Classes that have been renamed also have this applied. */ public class HasMappedReferenceProperty extends BasicProperty { public static final String KEY = "has-mapped-ref"; private static final HasMappedReferenceProperty INSTANCE = new HasMappedReferenceProperty(); private HasMappedReferenceProperty() { super(KEY, null); } /** * @param info * Class to mark as having a mapped reference. */ public static void set(@Nonnull ClassInfo info) { info.setProperty(INSTANCE); } /** * @param info * Class to strip this marker from. */ public static void remove(@Nonnull ClassInfo info) { info.removeProperty(KEY); } /** * @param info * Class to check for this marker on. * * @return {@code true} when the property is set on the class. */ public static boolean get(@Nonnull ClassInfo info) { return info.getProperties().containsKey(KEY); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/IllegalClassSuspectProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.FileInfo; import software.coley.recaf.info.properties.BasicProperty; import software.coley.recaf.info.properties.Property; /** * Built in property to track {@link FileInfo} instances that look like {@link ClassInfo} but could not be parsed. * * @author Matt Coley */ public class IllegalClassSuspectProperty extends BasicProperty { public static final IllegalClassSuspectProperty INSTANCE = new IllegalClassSuspectProperty(); public static final String KEY = "ill-class"; /** * New property value. */ private IllegalClassSuspectProperty() { super(KEY, true); } @Override public boolean persistent() { return false; } /** * @param info * File info instance * * @return {@code true} when the file is a suspected malformed class. */ public static boolean get(@Nonnull FileInfo info) { Property property = info.getProperty(KEY); if (property != null) { Boolean value = property.value(); return value != null && value; } return false; } /** * Marks the file as being a suspected malformed class. * * @param info * File info instance. */ public static void set(@Nonnull FileInfo info) { info.setProperty(IllegalClassSuspectProperty.INSTANCE); } /** * @param info * File info instance. */ public static void remove(@Nonnull FileInfo info) { info.removeProperty(KEY); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/InputFilePathProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.Info; import software.coley.recaf.info.properties.BasicProperty; import java.nio.file.Path; /** * Built in property for associating a {@link Path} with some info type. * * @author Matt Coley */ public class InputFilePathProperty extends BasicProperty { public static final String KEY = "input-file-path"; /** * @param value * Input path. */ public InputFilePathProperty(@Nonnull Path value) { super(KEY, value); } /** * @param info * Info instance. * * @return Input path associated with instance, or {@code null} when no association exists. */ @Nullable public static Path get(@Nonnull Info info) { return info.getPropertyValueOrNull(KEY); } /** * @param info * Info instance. * @param inputPath * Input path to associate with the item. */ public static void set(@Nonnull Info info, @Nonnull Path inputPath) { info.setProperty(new InputFilePathProperty(inputPath)); } /** * @param info * Info instance. */ public static void remove(@Nonnull Info info) { info.removeProperty(KEY); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/MemberIndexAcceleratorProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.ClassMember; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.info.properties.BasicProperty; import software.coley.recaf.info.properties.Property; import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.Objects; /** * Used to accelerate the {@link ClassMember} to {@code int index} lookup process in extreme scenarios. * * @author Matt Coley */ public class MemberIndexAcceleratorProperty extends BasicProperty> { public static final String KEY = "member-index-accel"; /** * Number of elements required to enable use of this property. */ public static final int CUTOFF = 1000; /** * @param classInfo * Target class to pull fields/methods from. */ public MemberIndexAcceleratorProperty(@Nonnull ClassInfo classInfo) { super(KEY, new IdentityHashMap<>()); Map value = Objects.requireNonNull(value()); List fields = classInfo.getFields(); for (int i = 0; i < fields.size(); i++) value.put(fields.get(i), i); List methods = classInfo.getMethods(); for (int i = 0; i < methods.size(); i++) value.put(methods.get(i), i); } /** * Get or creates an {@link MemberIndexAcceleratorProperty} for the given {@link ClassInfo}. * * @param classInfo * Target class to pull fields/methods from. * * @return Accelerator property for class. */ @Nonnull public static MemberIndexAcceleratorProperty get(@Nonnull ClassInfo classInfo) { Property property = classInfo.getProperty(KEY); if (property instanceof MemberIndexAcceleratorProperty acceleratorProperty) return acceleratorProperty; MemberIndexAcceleratorProperty acceleratorProperty = new MemberIndexAcceleratorProperty(classInfo); classInfo.setProperty(acceleratorProperty); return acceleratorProperty; } /** * @param member * Member to get an index of. Must match an exact instance of the member in the containing {@link ClassInfo}. * * @return Index of member, or {@code -1} if not present in the class. */ public int indexOf(@Nonnull ClassMember member) { return Objects.requireNonNull(value()).getOrDefault(member, -1); } @Override public boolean persistent() { // Member instances change between class info containers, so we dont want to persist this. return false; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/OriginalClassNameProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.properties.BasicProperty; import software.coley.recaf.services.mapping.MappingApplier; /** * Built in property to track the original name of an {@link ClassInfo}. * * @author Matt Coley * @see HasMappedReferenceProperty If a class isn't mapped, but has references to something that is, this is added. * @see MappingApplier Adds this property to renamed classes. */ public class OriginalClassNameProperty extends BasicProperty { public static final String KEY = "original-class-name"; /** * @param value * Original class name. */ public OriginalClassNameProperty(@Nonnull String value) { super(KEY, value); } /** * @param info * Class info instance. * * @return Original name of the class if set, otherwise the existing class name. */ @Nonnull public static String map(@Nonnull ClassInfo info) { String name = info.getName(); String original = info.getPropertyValueOrNull(KEY); if (original != null) return original; return name; } /** * @param info * Class info instance. * * @return Class name associated with instance, or {@code null} when no association exists. */ @Nullable public static String get(@Nonnull ClassInfo info) { return info.getPropertyValueOrNull(KEY); } /** * @param info * Class info instance. * @param original * Original class name to associate with the item. */ public static void set(@Nonnull ClassInfo info, @Nonnull String original) { info.setProperty(new OriginalClassNameProperty(original)); } /** * @param info * Class info instance. */ public static void remove(@Nonnull ClassInfo info) { info.removeProperty(KEY); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/PathOriginalNameProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.Info; import software.coley.recaf.info.properties.BasicProperty; /** * Built in property to track the original name of an {@link Info} type, primarily {@link ClassInfo} items. * * @author Matt Coley * @see PathPrefixProperty * @see PathSuffixProperty */ public class PathOriginalNameProperty extends BasicProperty { public static final String KEY = "path-original-full"; /** * @param value * Original path name. */ public PathOriginalNameProperty(@Nonnull String value) { super(KEY, value); } /** * @param info * Info instance. * * @return Original name of the info if set, otherwise the existing info name. */ @Nonnull public static String map(@Nonnull Info info) { String name = info.getName(); String original = info.getPropertyValueOrNull(KEY); if (original != null) return original; return name; } /** * @param info * Info instance. * * @return Original name associated with instance, or {@code null} when no association exists. */ @Nullable public static String get(@Nonnull Info info) { return info.getPropertyValueOrNull(KEY); } /** * @param info * Info instance. * @param original * Original name to associate with the item. */ public static void set(@Nonnull Info info, @Nonnull String original) { info.setProperty(new PathOriginalNameProperty(original)); } /** * @param info * Info instance. */ public static void remove(@Nonnull Info info) { info.removeProperty(KEY); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/PathPrefixProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.Info; import software.coley.recaf.info.properties.BasicProperty; /** * Built in property to track the original suffix of an {@link Info} type, primarily {@link ClassInfo} items. *
* Consider a WAR file, which prefixes all classes with {@link software.coley.recaf.info.WarFileInfo#WAR_CLASS_PREFIX}. * When we export the workspace, the {@link ClassInfo#getName()} will only yield the JVM name of the class, but not * the prefix. Thus, with this property holding the class prefix we can restore the full path of the {@link ClassInfo} * when exporting. * * @author Matt Coley * @see PathSuffixProperty * @see PathOriginalNameProperty */ public class PathPrefixProperty extends BasicProperty { public static final String KEY = "path-prefix"; /** * @param value * Prefix. */ public PathPrefixProperty(@Nonnull String value) { super(KEY, value); } /** * @param info * Info instance. * * @return Name of the info, with the suffix applied if any exist. */ @Nonnull public static String map(@Nonnull Info info) { String name = info.getName(); String prefix = get(info); if (prefix != null) return prefix + name; return name; } /** * @param info * Info instance. * * @return Prefix associated with instance, or {@code null} when no association exists. */ @Nullable public static String get(@Nonnull Info info) { return info.getPropertyValueOrNull(KEY); } /** * @param info * Info instance. * @param prefix * Suffix to associate with the item. */ public static void set(@Nonnull Info info, @Nonnull String prefix) { info.setProperty(new PathPrefixProperty(prefix)); } /** * @param info * Info instance. */ public static void remove(@Nonnull Info info) { info.removeProperty(KEY); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/PathSuffixProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.Info; import software.coley.recaf.info.properties.BasicProperty; /** * Built in property to track the suffix of an {@link Info} type, primarily the extension of {@link ClassInfo} files. * In most cases the value will be {@code .class}. * * @author Matt Coley * @see PathPrefixProperty * @see PathOriginalNameProperty */ public class PathSuffixProperty extends BasicProperty { public static final String KEY = "path-suffix"; /** * @param value * Suffix. */ public PathSuffixProperty(@Nonnull String value) { super(KEY, value); } /** * @param info * Info instance. * * @return Name of the info, with the suffix applied if any exist. */ @Nonnull public static String map(@Nonnull Info info) { String name = info.getName(); String suffix = get(info); if (suffix != null) return name + suffix; return name; } /** * @param info * Info instance. * * @return Suffix associated with instance, or {@code null} when no association exists. */ @Nullable public static String get(@Nonnull Info info) { return info.getPropertyValueOrNull(KEY); } /** * @param info * Info instance. * @param suffix * Suffix to associate with the item. */ public static void set(@Nonnull Info info, @Nonnull String suffix) { info.setProperty(new PathSuffixProperty(suffix)); } /** * @param info * Info instance. */ public static void remove(@Nonnull Info info) { info.removeProperty(KEY); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/ReferencedClassesProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.properties.BasicProperty; import java.util.Collection; import java.util.Collections; import java.util.NavigableSet; import java.util.Objects; import java.util.TreeSet; /** * Built in property to track what classes are referenced by this type. * * @author Matt Coley */ public class ReferencedClassesProperty extends BasicProperty> { public static final String KEY = "referenced-classes"; /** * @param classes * Collection of referenced classes. */ public ReferencedClassesProperty(@Nonnull Collection classes) { super(KEY, Collections.unmodifiableNavigableSet(new TreeSet<>(classes))); } /** * @param info * Info instance. * * @return Set of referenced classes, or {@code null} when no association exists. */ @Nullable public static NavigableSet get(@Nonnull ClassInfo info) { return info.getPropertyValueOrNull(KEY); } /** * @param info * Info instance. * @param classes * Collection of referenced classes. * * @return Sorted set of referenced classes assigned. */ @Nonnull public static NavigableSet set(@Nonnull ClassInfo info, @Nonnull Collection classes) { ReferencedClassesProperty property = new ReferencedClassesProperty(classes); info.setProperty(property); return Objects.requireNonNull(property.value()); } /** * @param info * Info instance. */ public static void remove(@Nonnull ClassInfo info) { info.removeProperty(KEY); } @Override public boolean persistent() { // Modifications to a class will likely invalidate this data return false; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/RemapOriginTaskProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.properties.BasicProperty; import software.coley.recaf.services.mapping.MappingApplier; import software.coley.recaf.services.mapping.MappingResults; import software.coley.recaf.workspace.model.resource.ResourceAndroidClassListener; import software.coley.recaf.workspace.model.resource.ResourceJvmClassListener; /** * Built in property associating the creation of a {@link ClassInfo} with a * mapping operation. This can be used to check if a class, when received in a listener * such as {@link ResourceJvmClassListener} or {@link ResourceAndroidClassListener} has * been created as a result of usage of {@link MappingApplier}. * * @author Matt Coley */ public class RemapOriginTaskProperty extends BasicProperty { public static final String KEY = "remap-origin-task"; /** * @param value * Property value. */ public RemapOriginTaskProperty(@Nonnull MappingResults value) { super(KEY, value); } @Override public boolean persistent() { return false; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/RemoteClassloaderProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import it.unimi.dsi.fastutil.ints.Int2ObjectArrayMap; import it.unimi.dsi.fastutil.ints.Int2ObjectMap; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.properties.BasicProperty; import software.coley.recaf.workspace.model.resource.WorkspaceRemoteVmResource; /** * Built in property to the classloader ID a {@link JvmClassInfo} is associated with * in a {@link WorkspaceRemoteVmResource}. * * @author Matt Coley */ public class RemoteClassloaderProperty extends BasicProperty { private static final Int2ObjectMap cache = new Int2ObjectArrayMap<>(); public static final String KEY = "remote-classloader-id"; /** * @param value * Loader ID */ private RemoteClassloaderProperty(int value) { super(KEY, value); } /** * @param classInfo * Class info to link with a classloader via its ID. * * @return Classloader ID associated with the class, * used as key for {@link WorkspaceRemoteVmResource#getJvmClassloaderBundles()}. * May be {@code null} if no loader association exists. */ @Nullable public static Integer get(@Nonnull JvmClassInfo classInfo) { return classInfo.getPropertyValueOrNull(KEY); } /** * @param classInfo * Class info to link with a version. * @param loaderId * Loader ID associated with the class, * used as key for {@link WorkspaceRemoteVmResource#getJvmClassloaderBundles()}. */ public static synchronized void set(@Nonnull JvmClassInfo classInfo, int loaderId) { synchronized (cache) { classInfo.setProperty(cache.computeIfAbsent(loaderId, RemoteClassloaderProperty::new)); } } /** * @param info * Info instance. */ public static void remove(@Nonnull JvmClassInfo info) { info.removeProperty(KEY); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/StringDefinitionsProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.properties.BasicProperty; import java.util.Collection; import java.util.SortedSet; import java.util.TreeSet; /** * Built in property to track what strings are defined by this type. * * @author Matt Coley */ public class StringDefinitionsProperty extends BasicProperty> { public static final String KEY = "strings"; /** * @param strings * Collection of defined strings. */ public StringDefinitionsProperty(@Nonnull Collection strings) { super(KEY, new TreeSet<>(strings)); } /** * @param info * Info instance. * * @return Set of defined strings, or {@code null} when no association exists. */ @Nullable public static SortedSet get(@Nonnull ClassInfo info) { return info.getPropertyValueOrNull(KEY); } /** * @param info * Info instance. * @param strings * Collection of defined strings. */ public static void set(@Nonnull ClassInfo info, @Nonnull Collection strings) { info.setProperty(new StringDefinitionsProperty(strings)); } /** * @param info * Info instance. */ public static void remove(@Nonnull ClassInfo info) { info.removeProperty(KEY); } @Override public boolean persistent() { // Modifications to a class will likely invalidate this data return false; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/ThrowableProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.properties.BasicProperty; import software.coley.recaf.info.properties.Property; /** * Built in property to track {@link ClassInfo} instances that inherit {@link Throwable} either directly * or indirectly. * * @author Matt Coley */ public class ThrowableProperty extends BasicProperty { public static final String KEY = "is-throwable"; /** * New property value. */ public ThrowableProperty() { super(KEY, true); } @Override public boolean persistent() { return false; } /** * @param info * Class info instance * * @return {@code true} when the class inherits from {@link Throwable}. */ public static boolean get(@Nonnull ClassInfo info) { Property property = info.getProperty(KEY); if (property != null) { Boolean value = property.value(); return value != null && value; } return false; } /** * Marks the class as inheriting {@link Throwable} * * @param info * Class info instance. */ public static void set(@Nonnull ClassInfo info) { info.setProperty(new ThrowableProperty()); } /** * @param info * Class info instance. */ public static void remove(@Nonnull ClassInfo info) { info.removeProperty(KEY); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/UnknownAttributesProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.properties.BasicProperty; import software.coley.recaf.info.properties.Property; import java.util.Collection; import java.util.Collections; import java.util.Objects; /** * Built in property to track if an {@link ClassInfo} contains unknown attributes. * * @author Matt Coley */ public class UnknownAttributesProperty extends BasicProperty> { public static final String KEY = "unknown-attr"; /** * @param unknownAttributeNames * Names of the unknown attributes. */ public UnknownAttributesProperty(@Nonnull Collection unknownAttributeNames) { super(KEY, unknownAttributeNames); } /** * @param info * Class to strip this marker from. */ public static void remove(@Nonnull ClassInfo info) { info.removeProperty(KEY); } /** * @param info * Class to check for this marker on. * * @return {@code true} when the property is set on the class. */ public static boolean has(@Nonnull ClassInfo info) { return info.getProperties().containsKey(KEY); } /** * @param info * Class to check for this marker on. * * @return Collection of unknown attributes on the class. */ @Nonnull public static Collection get(@Nonnull ClassInfo info) { Property property = info.getProperties().get(KEY); if (property instanceof UnknownAttributesProperty unknown) return Objects.requireNonNullElse(unknown.value(), Collections.emptyList()); return Collections.emptyList(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/VersionedClassProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import it.unimi.dsi.fastutil.ints.Int2ObjectArrayMap; import it.unimi.dsi.fastutil.ints.Int2ObjectMap; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.properties.BasicProperty; import software.coley.recaf.workspace.model.resource.WorkspaceResource; /** * Built in property to track the version a {@link JvmClassInfo} belongs to in * {@link WorkspaceResource#getVersionedJvmClassBundles()} * * @author Matt Coley */ public class VersionedClassProperty extends BasicProperty { private static final Int2ObjectMap cache = new Int2ObjectArrayMap<>(); public static final String KEY = "meta-inf-versioned"; /** * @param value * Version value */ private VersionedClassProperty(int value) { super(KEY, value); } /** * @param classInfo * Class info to link with a version. * * @return Version associated with the class, * used as key for {@link WorkspaceResource#getVersionedJvmClassBundles()}. * May be {@code null} if no version association exists. */ @Nullable public static Integer get(@Nonnull JvmClassInfo classInfo) { return classInfo.getPropertyValueOrNull(KEY); } /** * @param classInfo * Class info to link with a version. * @param version * Version associated with the class, * used as key for {@link WorkspaceResource#getVersionedJvmClassBundles()}. */ public static void set(@Nonnull JvmClassInfo classInfo, int version) { synchronized (cache) { classInfo.setProperty(cache.computeIfAbsent(version, VersionedClassProperty::new)); } } /** * @param classInfo * Class info to unlink with a version. */ public static void remove(@Nonnull JvmClassInfo classInfo) { classInfo.removeProperty(KEY); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/ZipAccessTimeProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.Info; import software.coley.recaf.info.properties.BasicProperty; import software.coley.recaf.info.properties.Property; /** * Built in property to track the original zip entry access time used for an {@link Info} * value stored inside a ZIP container. *
* Unlike modification time, access time is not part of the ZIP spec, so it is stored in the * extra data field. * * @author Matt Coley */ public class ZipAccessTimeProperty extends BasicProperty { public static final String KEY = "zip-access-time"; /** * @param value * Access time. */ public ZipAccessTimeProperty(long value) { super(KEY, value); } /** * @param info * Info instance. * * @return Access time. * {@code null} when no property value is assigned. */ @Nullable public static Long get(@Nonnull Info info) { Property property = info.getProperty(KEY); if (property != null) { return property.value(); } return null; } /** * @param info * Info instance. * @param fallback * Fallback time if there is no recorded type for the info instance. * * @return Access time. * {@code null} when no property value is assigned. */ public static long getOr(@Nonnull Info info, long fallback) { Long value = get(info); if (value == null) return fallback; return value; } /** * @param info * Info instance. * @param value * Access time. */ public static void set(@Nonnull Info info, long value) { info.setProperty(new ZipAccessTimeProperty(value)); } /** * @param info * Info instance. */ public static void remove(@Nonnull Info info) { info.removeProperty(KEY); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/ZipCommentProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.Info; import software.coley.recaf.info.properties.BasicProperty; import software.coley.recaf.info.properties.Property; /** * Built in property to track the original zip compression method used for an {@link Info} * value stored inside a ZIP container. * * @author Matt Coley */ public class ZipCommentProperty extends BasicProperty { public static final String KEY = "zip-comment"; /** * @param value * Optional comment. */ public ZipCommentProperty(@Nullable String value) { super(KEY, value); } /** * @param info * Info instance. * * @return Optional comment. * {@code null} when no property value is assigned. */ @Nullable public static String get(@Nonnull Info info) { Property property = info.getProperty(KEY); if (property != null) { return property.value(); } return null; } /** * @param info * Info instance. * @param fallback * Fallback comment if there is no recorded type for the info instance. * * @return Optional comment. * {@code null} when no property value is assigned. */ public static String getOr(@Nonnull Info info, String fallback) { String value = get(info); if (value == null) return fallback; return value; } /** * @param info * Info instance. * @param value * Optional comment. */ public static void set(@Nonnull Info info, @Nonnull String value) { info.setProperty(new ZipCommentProperty(value)); } /** * @param info * Info instance. */ public static void remove(@Nonnull Info info) { info.removeProperty(KEY); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/ZipCompressionProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.lljzip.format.compression.ZipCompressions; import software.coley.recaf.info.Info; import software.coley.recaf.info.properties.BasicProperty; import software.coley.recaf.info.properties.Property; /** * Built in property to track the original zip compression method used for an {@link Info} * value stored inside a ZIP container. * * @author Matt Coley */ public class ZipCompressionProperty extends BasicProperty { public static final String KEY = "zip-compression"; /** * @param value * Compression type. See {@link ZipCompressions} for values. */ public ZipCompressionProperty(int value) { super(KEY, value); } /** * @param info * Info instance. * * @return Compression type. See {@link ZipCompressions} for values. * {@code null} when no property value is assigned. */ @Nullable public static Integer get(@Nonnull Info info) { Property property = info.getProperty(KEY); if (property != null) { return property.value(); } return null; } /** * @param info * Info instance. * @param fallback * Fallback compression type if there is no recorded type for the info instance. * * @return Compression type. See {@link ZipCompressions} for values. * {@code fallback} when no property value is assigned. */ public static int getOr(@Nonnull Info info, int fallback) { Integer value = get(info); if (value == null) return fallback; return value; } /** * @param info * Info instance. * @param value * Compression type. See {@link ZipCompressions} for values. */ public static void set(@Nonnull Info info, int value) { info.setProperty(new ZipCompressionProperty(value)); } /** * @param info * Info instance. */ public static void remove(@Nonnull Info info) { info.removeProperty(KEY); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/ZipCreationTimeProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.Info; import software.coley.recaf.info.properties.BasicProperty; import software.coley.recaf.info.properties.Property; /** * Built in property to track the original zip entry creation time used for an {@link Info} * value stored inside a ZIP container. *
* Unlike modification time, creation time is not part of the ZIP spec, so it is stored in the * extra data field. * * @author Matt Coley */ public class ZipCreationTimeProperty extends BasicProperty { public static final String KEY = "zip-create-time"; /** * @param value * Creation time. */ public ZipCreationTimeProperty(long value) { super(KEY, value); } /** * @param info * Info instance. * * @return Creation time. * {@code null} when no property value is assigned. */ @Nullable public static Long get(@Nonnull Info info) { Property property = info.getProperty(KEY); if (property != null) { return property.value(); } return null; } /** * @param info * Info instance. * @param fallback * Fallback time if there is no recorded type for the info instance. * * @return Creation time. * {@code null} when no property value is assigned. */ public static long getOr(@Nonnull Info info, long fallback) { Long value = get(info); if (value == null) return fallback; return value; } /** * @param info * Info instance. * @param value * Creation time. */ public static void set(@Nonnull Info info, long value) { info.setProperty(new ZipCreationTimeProperty(value)); } /** * @param info * Info instance. */ public static void remove(@Nonnull Info info) { info.removeProperty(KEY); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/ZipEntryIndexProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.Info; import software.coley.recaf.info.properties.BasicProperty; import software.coley.recaf.info.properties.Property; /** * Built in property to track the original index of the file as it appears in the zip structure. * * @author Matt Coley */ public class ZipEntryIndexProperty extends BasicProperty { public static final String KEY = "zip-entry-index"; /** * @param value * The index of the local file in its containing zip archive. */ public ZipEntryIndexProperty(int value) { super(KEY, value); } @Override public boolean persistent() { return false; } /** * @param info * Info instance. * * @return Zip entry index. * {@code null} when no property value is assigned. */ @Nullable public static Integer get(@Nonnull Info info) { Property property = info.getProperty(KEY); if (property != null) { return property.value(); } return null; } /** * @param info * Info instance. * @param fallback * Fallback index if there is no recorded index for the info instance. * * @return Zip entry index. * {@code null} when no property value is assigned. */ public static int getOr(@Nonnull Info info, int fallback) { Integer value = get(info); if (value == null) return fallback; return value; } /** * @param info * Info instance. * @param value * Zip entry index. */ public static void set(@Nonnull Info info, int value) { info.setProperty(new ZipEntryIndexProperty(value)); } /** * @param info * Info instance. */ public static void remove(@Nonnull Info info) { info.removeProperty(KEY); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/ZipMarkerProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import software.coley.recaf.info.FileInfo; import software.coley.recaf.info.properties.BasicProperty; import software.coley.recaf.info.properties.Property; /** * Built in property to track {@link FileInfo} instances that have a ZIP file header within their contents. * * @author Matt Coley */ public class ZipMarkerProperty extends BasicProperty { public static final String KEY = "has-zip-marker"; /** * New property value. */ public ZipMarkerProperty() { super(KEY, true); } @Override public boolean persistent() { return false; } /** * @param info * File info instance * * @return {@code true} when the file has a ZIP file header in the contents. */ public static boolean get(@Nonnull FileInfo info) { Property property = info.getProperty(KEY); if (property != null) { Boolean value = property.value(); return value != null && value; } return false; } /** * Marks the class as inheriting containing a ZIP file header. * * @param info * File info instance. */ public static void set(@Nonnull FileInfo info) { info.setProperty(new ZipMarkerProperty()); } /** * @param info * File info instance. */ public static void remove(@Nonnull FileInfo info) { info.removeProperty(KEY); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/ZipModificationTimeProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.Info; import software.coley.recaf.info.properties.BasicProperty; import software.coley.recaf.info.properties.Property; /** * Built in property to track the original zip entry modification time used for an {@link Info} * value stored inside a ZIP container. * * @author Matt Coley */ public class ZipModificationTimeProperty extends BasicProperty { public static final String KEY = "zip-modify-time"; /** * @param value * Modification time. */ public ZipModificationTimeProperty(long value) { super(KEY, value); } /** * @param info * Info instance. * * @return Modification time. * {@code null} when no property value is assigned. */ @Nullable public static Long get(@Nonnull Info info) { Property property = info.getProperty(KEY); if (property != null) { return property.value(); } return null; } /** * @param info * Info instance. * @param fallback * Fallback time if there is no recorded type for the info instance. * * @return Modification time. * {@code null} when no property value is assigned. */ public static long getOr(@Nonnull Info info, long fallback) { Long value = get(info); if (value == null) return fallback; return value; } /** * @param info * Info instance. * @param value * Modification time. */ public static void set(@Nonnull Info info, long value) { info.setProperty(new ZipModificationTimeProperty(value)); } /** * @param info * Info instance. */ public static void remove(@Nonnull Info info) { info.removeProperty(KEY); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/ZipPrefixDataProperty.java ================================================ package software.coley.recaf.info.properties.builtin; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.Info; import software.coley.recaf.info.properties.BasicProperty; import software.coley.recaf.info.properties.Property; /** * Built in property to track data appearing before the ZIP header in an archive. * * @author Matt Coley */ public class ZipPrefixDataProperty extends BasicProperty { public static final String KEY = "zip-prefix-data"; /** * @param data * Optional data. */ public ZipPrefixDataProperty(@Nullable byte[] data) { super(KEY, data); } /** * @param info * Info instance. * * @return Optional data. * {@code null} when no property value is assigned. */ @Nullable public static byte[] get(@Nonnull Info info) { Property property = info.getProperty(KEY); if (property != null) { return property.value(); } return null; } /** * @param info * Info instance. * @param value * Optional data. */ public static void set(@Nonnull Info info, @Nonnull byte[] value) { info.setProperty(new ZipPrefixDataProperty(value)); } /** * @param info * Info instance. */ public static void remove(@Nonnull Info info) { info.removeProperty(KEY); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/launch/LaunchArguments.java ================================================ package software.coley.recaf.launch; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.services.file.RecafDirectoriesConfig; import java.io.File; /** * Launch arguments of Recaf. * * @author Matt Coley */ @ApplicationScoped public class LaunchArguments { private final RecafDirectoriesConfig directoriesConfig; private LaunchCommand command; private String[] args = new String[0]; @Inject public LaunchArguments(@Nonnull RecafDirectoriesConfig directoriesConfig) { this.directoriesConfig = directoriesConfig; } /** * @param command * PicoCli command impl for receiving inputs. */ public void setCommand(@Nonnull LaunchCommand command) { // Only allow setting it once. if (this.command == null) this.command = command; } /** * @param args * Literal args used to launch recaf. */ public void setRawArgs(@Nonnull String[] args) { this.args = args; } /** * @return Literal args used to launch recaf. */ @Nonnull public String[] getArgs() { return args; } /** * @return Input to load into a workspace on startup. */ @Nullable public File getInput() { if (command == null) return null; return command.getInput(); } /** * @return Script to run on startup. * * @see #getScriptInScriptsDirectory() Alternative to check for the existence of the script file path * in the script directory. */ @Nullable public File getScript() { if (command == null) return null; return command.getScript(); } /** * @return Script to run on startup. * * @see #getScript() Alternative to check for the existence of the script file path * relative to the current directory. */ @Nullable public File getScriptInScriptsDirectory() { File script = getScript(); if (script == null) return null; return directoriesConfig.getScriptsDirectory().resolve(script.getName()).toFile(); } /** * @return Flag to skip over initializing the UI. */ public boolean isHeadless() { if (command == null) return false; return command.isHeadless(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/launch/LaunchCommand.java ================================================ package software.coley.recaf.launch; import ch.qos.logback.classic.Level; import jakarta.annotation.Nullable; import jakarta.enterprise.inject.spi.Bean; import jakarta.enterprise.inject.spi.BeanManager; import picocli.CommandLine.Command; import picocli.CommandLine.Option; import software.coley.recaf.Bootstrap; import software.coley.recaf.RecafBuildConfig; import software.coley.recaf.analytics.SystemInformation; import software.coley.recaf.analytics.logging.RecafLoggingFilter; import software.coley.recaf.services.Service; import software.coley.recaf.util.StringUtil; import java.io.File; import java.io.StringWriter; import java.util.Comparator; import java.util.List; import java.util.concurrent.Callable; /** * Launch arguments for Recaf. * * @author Matt Coley * @see LaunchArguments Bean accesible form availble to CDI components. */ @Command(name = "recaf", mixinStandardHelpOptions = true, version = RecafBuildConfig.VERSION, description = "Recaf: The modern Java reverse engineering tool.") public class LaunchCommand implements Callable { @Option(names = {"-i", "--input"}, description = "Input to load into a workspace on startup.") private File input; @Option(names = {"-s", "--script"}, description = "Script to run on startup.") private File script; @Option(names = {"-d", "--datadir"}, description = "Override the directory to store recaf info within.") private File dataDir; @Option(names = {"-r", "--extraplugins"}, description = "Point to an external location to load additional plugins.") private File extraPluginDirectory; @Option(names = {"-h", "--headless"}, description = "Flag to skip over initializing the UI. Should be paired with -i or -s.") private boolean headless; @Option(names = {"-q", "--silent"}, description = "Disable slf4j logging to std-out.") private boolean silent; @Option(names = {"-v", "--version"}, description = "Display the version information.") private boolean version; @Option(names = {"-l", "--listservices"}, description = "Display the version information.") private boolean listServices; @Option(names = {"-p", "--listprops"}, description = "Display system properties.") private boolean dumpProperties; @Override public Boolean call() throws Exception { boolean ret = false; if (silent) RecafLoggingFilter.defaultLevel = Level.OFF; if (dataDir != null) System.setProperty("RECAF_DIR", dataDir.getAbsolutePath()); if (extraPluginDirectory != null) System.setProperty("RECAF_EXTRA_PLUGINS", extraPluginDirectory.getAbsolutePath()); if (version || listServices || dumpProperties) System.out.println("======================= RECAF ======================="); if (version) { System.out.printf(""" VERSION: %s GIT-COMMIT: %s GIT-TIME: %s GIT-BRANCH: %s ===================================================== """, RecafBuildConfig.VERSION, RecafBuildConfig.GIT_SHA, RecafBuildConfig.GIT_DATE, RecafBuildConfig.GIT_BRANCH ); ret = true; } if (listServices) { try { BeanManager beanManager = Bootstrap.get().getContainer().getBeanManager(); List> beans = beanManager.getBeans(Service.class).stream() .sorted(Comparator.comparing(o -> o.getBeanClass().getName())) .toList(); System.out.println("Services: " + beans.size()); for (Bean bean : beans) System.out.println(" - " + bean.getBeanClass().getName()); } catch (Throwable t) { System.out.println("Error occurred iterating over services..."); System.out.println(StringUtil.traceToString(t)); } System.out.println("====================================================="); ret = true; } if (dumpProperties) { StringWriter sw = new StringWriter(); SystemInformation.dump(sw); System.out.println(sw); System.out.println("====================================================="); ret = true; } return ret; } /** * @return Input to load into a workspace on startup. */ @Nullable public File getInput() { return input; } /** * @return Script to run on startup. */ @Nullable public File getScript() { return script; } /** * @return Flag to skip over initializing the UI. */ public boolean isHeadless() { return headless; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/launch/LaunchHandler.java ================================================ package software.coley.recaf.launch; import jakarta.annotation.PostConstruct; import jakarta.enterprise.context.ApplicationScoped; import software.coley.recaf.cdi.EagerInitialization; import software.coley.recaf.cdi.InitializationStage; /** * A special {@link EagerInitialization eagerly initialized bean} wrapper of {@link Runnable} which is specially by * the entry-point of the program. The lack of the {@link EagerInitialization} annotation on this type is intentional * as the entry-point determines if it should be run with {@link InitializationStage#IMMEDIATE} or * {@link InitializationStage#AFTER_UI_INIT} dynamically. * * @author Matt Coley */ @ApplicationScoped public class LaunchHandler { /** * Task to execute. */ public static Runnable task; @PostConstruct private void run() { if (task != null) task.run(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/path/AbstractPathNode.java ================================================ package software.coley.recaf.path; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.collections.Unchecked; import java.util.Objects; /** * Base implementation of {@link PathNode}. * * @param

* Expected parent path node value type. * @param * Wrapped path value type. * * @author Matt Coley */ public abstract class AbstractPathNode implements PathNode { private final PathNode

parent; private final Class valueType; private final String id; private final V value; /** * @param id * Unique node type ID. * @param parent * Optional parent node. * @param value * Value instance. */ protected AbstractPathNode(@Nonnull String id, @Nullable PathNode

parent, @Nonnull V value) { this.id = id; this.parent = parent; this.value = value; this.valueType = Unchecked.cast(value.getClass()); } /** * Convenient parent value getter. * * @return Parent value, or {@code null}. */ @Nullable protected P parentValue() { return parent == null ? null : parent.getValue(); } /** * @param path * Some other path. * * @return Comparing our parent value type to the given path, * and the other path parent value type to our own. * If we are the child type, then {@code -1} or {@link 1} if the parent type. * Otherwise {@code 0}. */ protected int cmpHierarchy(@Nonnull PathNode path) { if (getValueType() != path.getValueType()) { // Check direct parent (quicker validation) and then if that does not pass, a multi-level descendant test. if ((parent != null && parent.typeIdMatch(path)) || isDescendantOf(path)) { // We are the child type, show last. return 1; } // Check direct parent (quicker validation) and then if that does not pass, a multi-level descendant test. if ((path.getParent() != null && typeIdMatch(path.getParent())) || path.isDescendantOf(this)) { // We are the parent type, show first. return -1; } } // Unknown return 0; } /** * @param path * Some other path. * * @return Comparing {@link #getParent() the parent} to the given value. */ protected int cmpParent(@Nonnull PathNode path) { if (parent != null) return parent.compareTo(path); return 0; } @Override public PathNode

getParent() { return parent; } @Nonnull @Override public String typeId() { return id; } @Nonnull @Override public Class getValueType() { return valueType; } @Nonnull @Override @SuppressWarnings("all") public V getValue() { return value; } @Override public int compareTo(@Nonnull PathNode o) { if (this == o) return 0; int cmp = localCompare(o); if (cmp == 0) cmp = cmpHierarchy(o); if (cmp == 0) cmp = cmpParent(o); return cmp; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; PathNode node = (PathNode) o; return getValue().equals(node.getValue()) && Objects.equals(parent, node.getParent()); } @Override public int hashCode() { int hash = getValue().hashCode(); if (parent != null) return 31 * parent.hashCode() + hash; return hash; } @Override public String toString() { return value.toString(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/path/AnnotationPathNode.java ================================================ package software.coley.recaf.path; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.annotation.Annotated; import software.coley.recaf.info.annotation.AnnotationInfo; import java.util.Set; /** * Path node for annotations on {@link Annotated} types such as classes, fields, and methods. * * @author Matt Coley */ public class AnnotationPathNode extends AbstractPathNode { /** * Type identifier for annotation nodes. */ public static final String TYPE_ID = "annotation"; /** * Node without parent. * * @param annotation * Annotation. */ public AnnotationPathNode(@Nonnull AnnotationInfo annotation) { this(null, annotation); } /** * Node with parent. * * @param parent * Parent node. * @param annotation * Annotation. * * @see ClassMemberPathNode#childAnnotation(AnnotationInfo) * @see ClassPathNode#child(AnnotationInfo) * @see AnnotationPathNode#child(AnnotationInfo) * @see InnerClassPathNode#child(AnnotationInfo) */ @SuppressWarnings("unchecked") public AnnotationPathNode(@Nullable PathNode parent, @Nonnull AnnotationInfo annotation) { super(TYPE_ID, (PathNode) parent, annotation); } /** * @param annotation * Annotation to wrap into node. * * @return Path node of annotation, with the current annotation as parent. */ @Nonnull public AnnotationPathNode child(@Nonnull AnnotationInfo annotation) { return new AnnotationPathNode(this, annotation); } @Nonnull @Override public Set directParentTypeIds() { return Set.of(ClassPathNode.TYPE_ID, ClassMemberPathNode.TYPE_ID, InnerClassPathNode.TYPE_ID, AnnotationPathNode.TYPE_ID); } @Override public int localCompare(PathNode o) { if (this == o) return 0; if (o instanceof AnnotationPathNode node) { return getValue().getDescriptor().compareTo(node.getValue().getDescriptor()); } // Show before inner classes & annotations if (o instanceof InnerClassPathNode || o instanceof ClassMemberPathNode) return 1; return 0; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/path/BundlePathNode.java ================================================ package software.coley.recaf.path; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.Named; import software.coley.recaf.workspace.model.bundle.AndroidClassBundle; import software.coley.recaf.workspace.model.bundle.Bundle; import software.coley.recaf.workspace.model.bundle.VersionedJvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.Map; import java.util.Objects; import java.util.Set; /** * Path node for {@link Bundle} types. * * @author Matt Coley */ @SuppressWarnings("rawtypes") public class BundlePathNode extends AbstractPathNode { /** * Type identifier for bundle nodes. */ public static final String TYPE_ID = "bundle"; /** * Node without parent. * * @param bundle * Bundle value. */ public BundlePathNode(@Nonnull Bundle bundle) { this(null, bundle); } /** * Node with parent. * * @param parent * Parent node. * @param bundle * Bundle value. * * @see ResourcePathNode#child(Bundle) */ public BundlePathNode(@Nullable ResourcePathNode parent, @Nonnull Bundle bundle) { super(TYPE_ID, parent, bundle); } /** * @param directory * Directory to wrap in path node. * * @return Path node of directory, with the current bundle as parent. */ @Nonnull public DirectoryPathNode child(@Nullable String directory) { return new DirectoryPathNode(this, directory == null ? "" : directory); } /** * @return {@code true} when the path is in the resource's immediate JVM class bundle. */ public boolean isInJvmBundle() { WorkspaceResource resource = parentValue(); return resource != null && resource.getJvmClassBundle() == getValue(); } /** * @return {@code true} when the path is in one of the resource's Android bundles. */ @SuppressWarnings("all") public boolean isInAndroidBundle() { WorkspaceResource resource = parentValue(); return resource != null && resource.getAndroidClassBundles().containsValue(getValue()); } /** * @return {@code true} when the path is in the resource's immediate file bundle. */ public boolean isInFileBundle() { WorkspaceResource resource = parentValue(); return resource != null && resource.getFileBundle() == getValue(); } /** * @return {@code true} when the path is in one of the resource's versioned class bundles. */ public boolean isInVersionedJvmBundle() { WorkspaceResource resource = parentValue(); if (resource != null && getValue() instanceof VersionedJvmClassBundle jvmBundle) return resource.getVersionedJvmClassBundles().containsValue(jvmBundle); return false; } /** * @return Bit-mask used for ordering in {@link #compareTo(PathNode)}. */ private int bundleMask() { return ((isInJvmBundle() ? 1 : 0) << 10) | ((isInVersionedJvmBundle() ? 1 : 0) << 12) | ((isInAndroidBundle() ? 1 : 0) << 14) | ((isInFileBundle() ? 1 : 0) << 16); } @Override public ResourcePathNode getParent() { return (ResourcePathNode) super.getParent(); } @Nonnull @Override public Set directParentTypeIds() { return Set.of(ResourcePathNode.TYPE_ID); } @Override public boolean hasEqualOrChildValue(@Nonnull PathNode other) { // Bundle equality checks are abysmally slow, but also potentially problematic for path comparisons. // Two bundles may have the same contents, but they are not the same bundle. // We kill two birds with one stone by doing a reference check here. // If they are the same bundle, then they are the same path. if (other instanceof BundlePathNode otherBundlePath) return getValue() == otherBundlePath.getValue(); return super.hasEqualOrChildValue(other); } @Override public int localCompare(PathNode o) { if (this == o) return 0; if (o instanceof BundlePathNode bundlePathNode) { // Quick check for bundle reference equality. If they are the same bundle, then they are the same path. Bundle bundle = getValue(); Bundle otherBundle = bundlePathNode.getValue(); if (bundle == otherBundle) return 0; // Order bundles by type. // This is a bitmask that encodes the bundle type which also doubles as order preference. int cmp = Integer.compare(bundleMask(), bundlePathNode.bundleMask()); if (cmp != 0) return cmp; // Order dex class bundles to be in alphabetical order. if (getParent() != null && bundle instanceof AndroidClassBundle && otherBundle instanceof AndroidClassBundle) { WorkspaceResource resource = getParent().getValue(); Set> androidBundles = resource.getAndroidClassBundles().entrySet(); String dexName = androidBundles.stream() .filter(e -> e.getValue() == bundle) .map(Map.Entry::getKey) .findFirst() .orElse(null); String otherDexName = androidBundles.stream() .filter(e -> e.getValue() == otherBundle) .map(Map.Entry::getKey) .findFirst() .orElse(null); return Named.STRING_PATH_COMPARATOR.compare(dexName, otherDexName); } // Order versioned JVM class bundles by version number. else if (bundle instanceof VersionedJvmClassBundle versionedBundle && otherBundle instanceof VersionedJvmClassBundle otherVersionedBundle) { return Integer.compare(versionedBundle.version(), otherVersionedBundle.version()); } } return 0; } @Override public boolean equals(Object o) { // Bundle equality checks are abysmally slow because are checking if all the contained // contents are also equal. Realistically we can get away with a reference check. if (o instanceof BundlePathNode otherPath) return getValue() == otherPath.getValue() && Objects.equals(getParent(), otherPath.getParent()); return false; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/path/CatchPathNode.java ================================================ package software.coley.recaf.path; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.member.ClassMember; import software.coley.recaf.info.member.MethodMember; import java.util.Set; /** * Path node for {@code catch(Exception)} within {@link MethodMember} instances. * * @author Matt Coley */ public class CatchPathNode extends AbstractPathNode { /** * Type identifier for catch nodes. */ public static final String TYPE_ID = "catch"; /** * Node without parent. * * @param type * Exception type. */ public CatchPathNode(@Nonnull String type) { this(null, type); } /** * Node with parent. * * @param parent * Parent node. * @param type * Exception type. * * @see ClassMemberPathNode#childCatch(String) */ public CatchPathNode(@Nullable ClassMemberPathNode parent, @Nonnull String type) { super(TYPE_ID, parent, type); } @Override public ClassMemberPathNode getParent() { return (ClassMemberPathNode) super.getParent(); } @Nonnull @Override public Set directParentTypeIds() { return Set.of(ClassMemberPathNode.TYPE_ID); } @Override public int localCompare(PathNode o) { if (this == o) return 0; if (o instanceof CatchPathNode node) return getValue().compareTo(node.getValue()); else if (o instanceof LocalVariablePathNode || o instanceof InstructionPathNode) return -1; else if (o instanceof ThrowsPathNode) return 1; return 0; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/path/ClassMemberPathNode.java ================================================ package software.coley.recaf.path; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.objectweb.asm.tree.AbstractInsnNode; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.Named; import software.coley.recaf.info.annotation.AnnotationInfo; import software.coley.recaf.info.member.ClassMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.properties.builtin.MemberIndexAcceleratorProperty; import java.util.List; import java.util.Objects; import java.util.Set; /** * Path node for {@link ClassMember} types. * * @author Matt Coley */ public class ClassMemberPathNode extends AbstractPathNode { /** * Type identifier for member nodes. */ public static final String TYPE_ID = "member"; /** * Node without parent. * * @param member * Member value. */ public ClassMemberPathNode(@Nonnull ClassMember member) { this(null, member); } /** * Node with parent. * * @param parent * Parent node. * @param member * Member value. * * @see ClassPathNode#child(ClassMember) */ public ClassMemberPathNode(@Nullable ClassPathNode parent, @Nonnull ClassMember member) { super(TYPE_ID, parent, member); } /** * @return {@code true} when wrapping a field. */ public boolean isField() { return getValue().isField(); } /** * @return {@code true} when wrapping a method. */ public boolean isMethod() { return getValue().isMethod(); } /** * @param thrownType * Thrown type to wrap into node. * * @return Path node of thrown type, with the current member as parent. */ @Nonnull public ThrowsPathNode childThrows(@Nonnull String thrownType) { if (isMethod()) return new ThrowsPathNode(this, thrownType); throw new IllegalStateException("Cannot make child for throws on non-method member"); } /** * @param exceptionType * Thrown type to wrap into node. * * @return Path node of caught type, with the current member as parent. */ @Nonnull public CatchPathNode childCatch(@Nonnull String exceptionType) { if (isMethod()) return new CatchPathNode(this, exceptionType); throw new IllegalStateException("Cannot make child for catch on non-method member"); } /** * @param annotation * Annotation to wrap into node. * * @return Path node of annotation, with the current member as parent. */ @Nonnull public AnnotationPathNode childAnnotation(@Nonnull AnnotationInfo annotation) { return new AnnotationPathNode(this, annotation); } /** * @param variable * Variable to wrap into node. * * @return Path node of local variable, with the current member as parent. */ @Nonnull public LocalVariablePathNode childVariable(LocalVariable variable) { if (isMethod()) return new LocalVariablePathNode(this, variable); throw new IllegalStateException("Cannot make child for catch on non-method member"); } /** * @param insn * Instruction to wrap into node. * @param index * Index of the instruction within the method code. * * @return Path node of instruction, with the current member as parent. */ @Nonnull public InstructionPathNode childInsn(@Nonnull AbstractInsnNode insn, int index) { if (isMethod()) return new InstructionPathNode(this, insn, index); throw new IllegalStateException("Cannot make child for insn on non-method member"); } @Override public boolean hasEqualOrChildValue(@Nonnull PathNode other) { if (other instanceof ClassMemberPathNode otherMemberPath) { ClassMember member = getValue(); ClassMember otherMember = otherMemberPath.getValue(); // We'll determine equality just by the name+type of the contained member. // Path equality should match by location, so comparing just by name+type // allows this path and the other path to have different versions of // the same member. return member.getName().equals(otherMember.getName()) && member.getDescriptor().equals(otherMember.getDescriptor()); } return false; } @Override public ClassPathNode getParent() { return (ClassPathNode) super.getParent(); } @Nonnull @Override public Set directParentTypeIds() { return Set.of(ClassPathNode.TYPE_ID); } @Override public int localCompare(PathNode o) { if (this == o) return 0; if (o instanceof ClassMemberPathNode classMemberNode) { ClassMember member = getValue(); ClassMember otherMember = classMemberNode.getValue(); // Show fields first if (member.isField() && otherMember.isMethod()) { return -1; } else if (member.isMethod() && otherMember.isField()) { return 1; } int cmp; ClassPathNode parent = getParent(); if (parent != null) { ClassPathNode otherParent = classMemberNode.getParent(); if (otherParent != null) { cmp = parent.compareTo(otherParent); if (cmp != 0) return cmp; } // Sort by appearance order in parent. ClassInfo classInfo = parent.getValue(); List list = member.isField() ? classInfo.getFields() : classInfo.getMethods(); if (list.size() > MemberIndexAcceleratorProperty.CUTOFF) { MemberIndexAcceleratorProperty accel = MemberIndexAcceleratorProperty.get(classInfo); cmp = Integer.compare(accel.indexOf(member), accel.indexOf(otherMember)); } else { cmp = Integer.compare(list.indexOf(member), list.indexOf(otherMember)); } } else { // Just sort alphabetically if parent not known. String key = member.getName() + member.getDescriptor(); String otherKey = otherMember.getName() + member.getDescriptor(); cmp = Named.STRING_COMPARATOR.compare(key, otherKey); } return cmp; } // Show after inner classes & annotations if (o instanceof InnerClassPathNode || o instanceof AnnotationPathNode) return 1; return 0; } @Override public boolean equals(Object o) { // If the member names/signatures are the same and the parent paths are also equal, then this path points to the same location. if (o instanceof ClassMemberPathNode otherPath) return getValue().getName().equals(otherPath.getValue().getName()) && getValue().getDescriptor().equals(otherPath.getValue().getDescriptor()) && Objects.equals(getParent(), otherPath.getParent()); return false; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/path/ClassPathNode.java ================================================ package software.coley.recaf.path; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.InnerClassInfo; import software.coley.recaf.info.Named; import software.coley.recaf.info.annotation.AnnotationInfo; import software.coley.recaf.info.member.ClassMember; import software.coley.recaf.workspace.model.bundle.ClassBundle; import java.util.Objects; import java.util.Set; /** * Path node for {@link ClassInfo} types. * * @author Matt Coley */ public class ClassPathNode extends AbstractPathNode { /** * Type identifier for class nodes. */ public static final String TYPE_ID = "class"; /** * Node without parent. * * @param info * Class value. */ public ClassPathNode(@Nonnull ClassInfo info) { this(null, info); } /** * Node with parent. * * @param parent * Parent node. * @param info * Class value. * * @see DirectoryPathNode#child(ClassInfo) */ public ClassPathNode(@Nullable DirectoryPathNode parent, @Nonnull ClassInfo info) { super(TYPE_ID, parent, info); } /** * @param name * Field or method name in the current class. * @param desc * Field or method descriptor in the current class. * * @return Path node of member, with the current class as parent. * {@code null} if a field or method with the given name/descriptor could not be found. */ @Nullable public ClassMemberPathNode child(@Nonnull String name, @Nonnull String desc) { ClassInfo classInfo = getValue(); ClassMember member; if (!desc.isEmpty() && desc.charAt(0) == '(') member = classInfo.getDeclaredMethod(name, desc); else member = classInfo.getDeclaredField(name, desc); if (member != null) return child(member); return null; } /** * @param member * Member to wrap into node. * * @return Path node of member, with the current class as parent. */ @Nonnull public ClassMemberPathNode child(@Nonnull ClassMember member) { return new ClassMemberPathNode(this, member); } /** * @param innerClass * Inner class to wrap into node. * * @return Path node of inner class, with the current class as parent. */ @Nonnull public InnerClassPathNode child(@Nonnull InnerClassInfo innerClass) { return new InnerClassPathNode(this, innerClass); } /** * @param annotation * Annotation to wrap into node. * * @return Path node of annotation, with the current member as parent. */ @Nonnull public AnnotationPathNode child(@Nonnull AnnotationInfo annotation) { return new AnnotationPathNode(this, annotation); } @Nonnull @Override public ClassPathNode withCurrentWorkspaceContent() { DirectoryPathNode parent = getParent(); if (parent == null) return this; ClassBundle bundle = getValueOfType(ClassBundle.class); if (bundle == null) return this; ClassInfo classInfo = bundle.get(getValue().getName()); if (classInfo == null || classInfo == getValue()) return this; return parent.child(classInfo); } @Override public boolean hasEqualOrChildValue(@Nonnull PathNode other) { if (other instanceof ClassPathNode otherClassPath) { ClassInfo cls = getValue(); ClassInfo otherCls = otherClassPath.getValue(); // We'll determine equality just by the name of the contained class. // Path equality should match by location, so comparing just by name // allows this path and the other path to have different versions of // the same class. return cls.getName().equals(otherCls.getName()); } return false; } @Override public DirectoryPathNode getParent() { return (DirectoryPathNode) super.getParent(); } @Nonnull @Override public Set directParentTypeIds() { return Set.of(DirectoryPathNode.TYPE_ID); } @Override public int localCompare(PathNode o) { if (this == o) return 0; if (o instanceof ClassPathNode classPathNode) { String name = getValue().getName(); String otherName = classPathNode.getValue().getName(); return Named.STRING_PATH_COMPARATOR.compare(name, otherName); } return 0; } @Override public boolean equals(Object o) { // If the class names are the same and the parent paths are also equal, then this path points to the same location. if (o instanceof ClassPathNode otherPath) { String name = getValue().getName(); String otherName = otherPath.getValue().getName(); return name.hashCode() == otherName.hashCode() // Hash check first which is very fast, and the result is cached. && name.equals(otherName) // Sanity check for matching items to prevent hash collisions. && Objects.equals(getParent(), otherPath.getParent()); // Parents must also match. } return false; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/path/DirectoryPathNode.java ================================================ package software.coley.recaf.path; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.FileInfo; import software.coley.recaf.info.Named; import software.coley.recaf.workspace.model.bundle.Bundle; import java.util.Objects; import java.util.Set; /** * Path node for packages of {@link ClassInfo} and directories of {@link FileInfo} types. * * @author Matt Coley */ @SuppressWarnings("rawtypes") public class DirectoryPathNode extends AbstractPathNode { /** * Type identifier for directory nodes. */ public static final String TYPE_ID = "directory"; /** * Node without parent. * * @param directory * Directory name. */ public DirectoryPathNode(@Nonnull String directory) { this(null, directory); } /** * Node with parent. * * @param parent * Parent node. * @param directory * Directory name. * * @see BundlePathNode#child(String) */ public DirectoryPathNode(@Nullable BundlePathNode parent, @Nonnull String directory) { super(TYPE_ID, parent, directory); } /** * @param directory * New directory name. * * @return New node with same parent, but different directory name value. */ @Nonnull public DirectoryPathNode withDirectory(@Nonnull String directory) { return new DirectoryPathNode(getParent(), directory); } /** * @param classInfo * Class to wrap into node. * * @return Path node of class, with the current package as parent. */ @Nonnull public ClassPathNode child(@Nonnull ClassInfo classInfo) { return new ClassPathNode(this, classInfo); } /** * @param fileInfo * File to wrap into node. * * @return Path node of file, with the current directory as parent. */ @Nonnull public FilePathNode child(@Nonnull FileInfo fileInfo) { return new FilePathNode(this, fileInfo); } @Override @SuppressWarnings("all") public BundlePathNode getParent() { return (BundlePathNode) super.getParent(); } @Nonnull @Override public Set directParentTypeIds() { return Set.of(BundlePathNode.TYPE_ID, DirectoryPathNode.TYPE_ID); } @Override public boolean hasEqualOrChildValue(@Nonnull PathNode other) { if (other instanceof DirectoryPathNode otherDirectory) { String dir = getValue(); String maybeParentDir = otherDirectory.getValue(); // We cannot do just a basic 'startsWith' check on the path values since they do not // end with a trailing slash. This could lead to cases where: // 'co' is a parent value of 'com/foo' // // By doing an equals check, we allow for 'co' vs 'com' to fail but 'co' vs 'co' to pass, // and the following startsWith check with a slash allows us to not fall to the suffix issue described above. return dir.equals(maybeParentDir) || dir.startsWith(maybeParentDir + "/"); } return super.hasEqualOrChildValue(other); } @Override public boolean isDescendantOf(@Nonnull PathNode other) { // Descendant check comparing between directories will check for containment within the local value's path. // This way 'a/b/c' is seen as a descendant of 'a/b'. if (typeId().equals(other.typeId())) return hasEqualOrChildValue(other) && allParentsMatch(other); return super.isDescendantOf(other); } @Override public int localCompare(PathNode o) { if (this == o) return 0; if (o instanceof DirectoryPathNode pathNode) { String name = getValue(); String otherName = pathNode.getValue(); return Named.STRING_PATH_COMPARATOR.compare(name, otherName); } return 0; } @Override public boolean equals(Object o) { // If the directories are the same and the parent paths are also equal, then this path points to the same location. if (o instanceof DirectoryPathNode otherPath) { String dir = getValue(); String otherDir = otherPath.getValue(); return dir.hashCode() == otherDir.hashCode() // Hash check first which is very fast, and the result is cached. && dir.equals(otherDir) // Sanity check for matching items to prevent hash collisions. && Objects.equals(getParent(), otherPath.getParent()); // Parents must also match. } return false; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/path/EmbeddedResourceContainerPathNode.java ================================================ package software.coley.recaf.path; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.Set; /** * Path node for housing one or more embedded resources in another resource. * * @author Matt Coley */ public class EmbeddedResourceContainerPathNode extends AbstractPathNode { /** * Type identifier for embedded containers. */ public static final String TYPE_ID = "embedded-container"; /** * Node with parent. * * @param parent * Parent node. * @param workspace * Workspace containing the host resource. */ public EmbeddedResourceContainerPathNode(@Nullable ResourcePathNode parent, @Nonnull Workspace workspace) { super(TYPE_ID, parent, workspace); } /** * @param resource * Resource to wrap into node. * * @return Path node of resource, with the current workspace as parent. */ @Nonnull public ResourcePathNode child(@Nonnull WorkspaceResource resource) { return new ResourcePathNode(this, resource); } @Override public ResourcePathNode getParent() { return (ResourcePathNode) super.getParent(); } @Nonnull @Override public Set directParentTypeIds() { return Set.of(EmbeddedResourceContainerPathNode.TYPE_ID); } @Override public int localCompare(PathNode o) { return 0; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/path/FilePathNode.java ================================================ package software.coley.recaf.path; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.FileInfo; import software.coley.recaf.info.Named; import software.coley.recaf.workspace.model.bundle.FileBundle; import java.util.Objects; import java.util.Set; /** * Path node for {@link FileInfo} types. * * @author Matt Coley */ public class FilePathNode extends AbstractPathNode { /** * Type identifier for file nodes. */ public static final String TYPE_ID = "file"; /** * Node without parent. * * @param info * File value. */ public FilePathNode(@Nonnull FileInfo info) { this(null, info); } /** * Node with parent. * * @param parent * Parent node. * @param info * File value. * * @see DirectoryPathNode#child(FileInfo) */ public FilePathNode(@Nullable DirectoryPathNode parent, @Nonnull FileInfo info) { super(TYPE_ID, parent, info); } /** * @param lineNo * Line number to wrap into node. * * @return Path node of line number, with the current file as parent. */ @Nonnull public LineNumberPathNode child(int lineNo) { return new LineNumberPathNode(this, lineNo); } @Nonnull @Override public FilePathNode withCurrentWorkspaceContent() { DirectoryPathNode parent = getParent(); if (parent == null) return this; FileBundle bundle = getValueOfType(FileBundle.class); if (bundle == null) return this; FileInfo fileInfo = bundle.get(getValue().getName()); if (fileInfo == null || fileInfo == getValue()) return this; return parent.child(fileInfo); } @Override public boolean hasEqualOrChildValue(@Nonnull PathNode other) { if (other instanceof FilePathNode otherClassPathNode) { FileInfo cls = getValue(); FileInfo otherCls = otherClassPathNode.getValue(); // We'll determine equality just by the name of the contained file. // Path equality should match by location, so comparing just by name // allows this path and the other path to have different versions of // the same file. return cls.getName().equals(otherCls.getName()); } return false; } @Override public DirectoryPathNode getParent() { return (DirectoryPathNode) super.getParent(); } @Nonnull @Override public Set directParentTypeIds() { return Set.of(DirectoryPathNode.TYPE_ID); } @Override public int localCompare(PathNode o) { if (this == o) return 0; if (o instanceof FilePathNode fileNode) { String name = getValue().getName(); String otherName = fileNode.getValue().getName(); return Named.STRING_PATH_COMPARATOR.compare(name, otherName); } return 0; } @Override public boolean equals(Object o) { // If the file names are the same and the parent paths are also equal, then this path points to the same location. if (o instanceof FilePathNode otherPath) { String name = getValue().getName(); String otherName = otherPath.getValue().getName(); return name.hashCode() == otherName.hashCode() // Hash check first which is very fast, and the result is cached. && name.equals(otherName) // Sanity check for matching items to prevent hash collisions. && Objects.equals(getParent(), otherPath.getParent()); // Parents must also match. } return false; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/path/IncompletePathException.java ================================================ package software.coley.recaf.path; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; /** * Used by methods when handling input {@link PathNode} content indicates an element in the path is missing. * * @author Matt Coley */ public class IncompletePathException extends Exception { private final Class missingType; /** * @param missingType * Missing type in the path. */ public IncompletePathException(@Nonnull Class missingType) { this(missingType, null); } /** * @param missingType * Missing type in the path. * @param message * Problem message. */ public IncompletePathException(@Nonnull Class missingType, @Nullable String message) { super(message); this.missingType = missingType; } /** * @return Missing type in the path. */ @Nonnull public Class getMissingType() { return missingType; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/path/InnerClassPathNode.java ================================================ package software.coley.recaf.path; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.InnerClassInfo; import software.coley.recaf.info.Named; import software.coley.recaf.info.annotation.AnnotationInfo; import java.util.Set; /** * Path node for {@link InnerClassInfo} types. * * @author Matt Coley */ public class InnerClassPathNode extends AbstractPathNode { /** * Type identifier for inner class nodes. */ public static final String TYPE_ID = "inner-class"; /** * Node with parent. * * @param parent * Optional parent node. * @param innerClass * Inner class instance. * * @see ClassPathNode#child(InnerClassInfo) */ public InnerClassPathNode(@Nullable ClassPathNode parent, @Nonnull InnerClassInfo innerClass) { super(TYPE_ID, parent, innerClass); } /** * @param annotation * Annotation to wrap into node. * * @return Path node of annotation, with the current inner class as parent. */ @Nonnull public AnnotationPathNode child(@Nonnull AnnotationInfo annotation) { return new AnnotationPathNode(this, annotation); } @Override public ClassPathNode getParent() { return (ClassPathNode) super.getParent(); } @Nonnull @Override public Set directParentTypeIds() { return Set.of(ClassPathNode.TYPE_ID); } @Override public int localCompare(PathNode o) { if (this == o) return 0; if (o instanceof InnerClassPathNode innerClassPathNode) { String name = getValue().getInnerClassName(); String otherName = innerClassPathNode.getValue().getInnerClassName(); return Named.STRING_PATH_COMPARATOR.compare(name, otherName); } // Show before members if (o instanceof ClassMemberPathNode) return -1; // Show after annos if (o instanceof AnnotationPathNode) return 1; return 0; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/path/InstructionPathNode.java ================================================ package software.coley.recaf.path; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.objectweb.asm.tree.AbstractInsnNode; import software.coley.recaf.info.member.ClassMember; import software.coley.recaf.info.member.MethodMember; import java.util.Set; /** * Path node for instructions within {@link MethodMember} instances. * * @author Matt Coley */ public class InstructionPathNode extends AbstractPathNode { /** * Type identifier for instruction nodes. */ public static final String TYPE_ID = "instruction"; private final int index; /** * Node without parent. * * @param insn * Instruction value. * @param index * Index of the instruction within the method code. */ public InstructionPathNode(@Nonnull AbstractInsnNode insn, int index) { this(null, insn, index); } /** * Node with parent. * * @param parent * Parent node. * @param insn * Instruction value. * @param index * Index of the instruction within the method code. * * @see ClassMemberPathNode#childInsn(AbstractInsnNode, int) */ public InstructionPathNode(@Nullable ClassMemberPathNode parent, @Nonnull AbstractInsnNode insn, int index) { super(TYPE_ID, parent, insn); this.index = index; } /** * @return Index of the instruction within the method code (As determined by ASM). */ public int getInstructionIndex() { return index; } @Override public ClassMemberPathNode getParent() { return (ClassMemberPathNode) super.getParent(); } @Nonnull @Override public Set directParentTypeIds() { return Set.of(ClassMemberPathNode.TYPE_ID); } @Override public int localCompare(PathNode o) { if (this == o) return 0; if (o instanceof InstructionPathNode node) return Integer.compare(index, node.index); else if (o instanceof ThrowsPathNode || o instanceof CatchPathNode || o instanceof LocalVariablePathNode) return 1; return 0; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/path/LineNumberPathNode.java ================================================ package software.coley.recaf.path; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.FileInfo; import java.util.Set; /** * Path node for line numbers within text {@link FileInfo} instances. * * @author Matt Coley */ public class LineNumberPathNode extends AbstractPathNode { /** * Type identifier for line number nodes. */ public static final String TYPE_ID = "line"; /** * Node without parent. * * @param line * Line number value. */ public LineNumberPathNode(int line) { this(null, line); } /** * Node with parent. * * @param parent * Parent node. * @param line * Line number value. * * @see FilePathNode#child(int) */ public LineNumberPathNode(@Nullable FilePathNode parent, int line) { super(TYPE_ID, parent, line); } @Override public FilePathNode getParent() { return (FilePathNode) super.getParent(); } @Nonnull @Override public Set directParentTypeIds() { return Set.of(FilePathNode.TYPE_ID); } @Override public int localCompare(PathNode o) { if (this == o) return 0; if (o instanceof LineNumberPathNode lineNode) { int i = getValue().compareTo(lineNode.getValue()); if (i == 0) { // Fall back to parent file comparison if the local line numbers are the same. // Not ideal, but we can't validate anything else here. FilePathNode parent = getParent(); FilePathNode otherParent = lineNode.getParent(); if (parent != null && otherParent != null) i = parent.localCompare(otherParent); } return i; } return 0; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/path/LocalVariablePathNode.java ================================================ package software.coley.recaf.path; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.member.ClassMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import java.util.Set; /** * Path node for local variables within {@link MethodMember} instances. * * @author Matt Coley */ public class LocalVariablePathNode extends AbstractPathNode { /** * Type identifier for local variable nodes. */ public static final String TYPE_ID = "variable"; /** * Node without parent. * * @param variable * Variable value. */ public LocalVariablePathNode(@Nonnull LocalVariable variable) { this(null, variable); } /** * Node with parent. * * @param parent * Parent node. * @param variable * Variable value. * * @see ClassMemberPathNode#childVariable(LocalVariable) */ public LocalVariablePathNode(@Nullable ClassMemberPathNode parent, @Nonnull LocalVariable variable) { super(TYPE_ID, parent, variable); } @Override public ClassMemberPathNode getParent() { return (ClassMemberPathNode) super.getParent(); } @Nonnull @Override public Set directParentTypeIds() { return Set.of(ClassMemberPathNode.TYPE_ID); } @Override public int localCompare(PathNode o) { if (this == o) return 0; if (o instanceof LocalVariablePathNode node) { LocalVariable value = getValue(); LocalVariable otherValue = node.getValue(); int cmp = Integer.compare(value.getIndex(), otherValue.getIndex()); if (cmp == 0) cmp = value.getName().compareTo(otherValue.getName()); if (cmp == 0) cmp = value.getDescriptor().compareTo(otherValue.getDescriptor()); return cmp; } else if (o instanceof ThrowsPathNode || o instanceof CatchPathNode) return 1; else if (o instanceof InstructionPathNode) return -1; return 0; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/path/PathNode.java ================================================ package software.coley.recaf.path; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.Bundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.Set; import java.util.function.Consumer; /** * A "modular" value type for representing "paths" to content in a {@link Workspace}. * The path must contain all data in a "chain" such that it can have access from most specific portion * all the way up to the {@link Workspace} portion. *

* NOTE: Regarding contents in embedded resources, the path result of the methods like * {@link Workspace#findClass(String)} will contain the root {@link WorkspaceResource} but the exact {@link Bundle}. * To find the exact embedded resource of a result use {@link WorkspaceResource#resolveBundleContainer(Bundle)}. * * @param * Path value type. * * @author Matt Coley */ public interface PathNode extends Comparable> { /** * The parent node of this node. This value does not have to be present in the actual UI model. * The parent linkage is so that child types like {@link ClassPathNode} can access their full scope, * including their containing {@link DirectoryPathNode package}, {@link BundlePathNode bundle}, * {@link ResourcePathNode resource}, and {@link WorkspacePathNode workspace}. *
* This allows child-types such as {@link ClassPathNode} to be passed around to consuming APIs and retain access * to the mentioned scoped values. * * @return Parent node. * * @see #getValueOfType(Class) Used by child-types to look up values in themselves, and their parents. */ @Nullable @SuppressWarnings("rawtypes") PathNode getParent(); /** * Creates a copy of the path node with this child-most node's value being looked up for a newer * value in the associated workspace. *

* Note: A {@link WorkspacePathNode} must be present. * * @return A new path node pointing to the same location, * but with the {@link #getValue() value} being updated to the current in the associated {@link Workspace}. * If a lookup could not be done then the current instance is returned. */ @Nonnull @SuppressWarnings("rawtypes") default PathNode withCurrentWorkspaceContent() { return this; } /** * @return Wrapped value. */ @Nonnull V getValue(); /** * @param other * Some other path node. * * @return {@code true} when the other path has the same {@link #getValue() local value}. */ default boolean hasEqualOrChildValue(@Nonnull PathNode other) { V value = getValue(); Object otherValue = other.getValue(); return this == other || value == otherValue || value.equals(otherValue); } /** * Used to differentiate path nodes in a chain that have the same {@link #getValueType()}. * * @return String unique ID per path-node type. */ @Nonnull String typeId(); /** * @param node * Other node to check. * * @return {@code true} when the current {@link #typeId()} is the same as the other's ID. */ default boolean typeIdMatch(@Nonnull PathNode node) { return typeId().equals(node.typeId()); } /** * @return Set of expected {@link #typeId()} values for {@link #getParent() parent nodes}. */ @Nonnull Set directParentTypeIds(); /** * @return The type of this path node's {@link #getValue() wrapped value}. */ @Nonnull Class getValueType(); /** * @param type * Some type contained in the full path. * This includes the current {@link PathNode} and any {@link #getParent() parent}. * @param * Implied value type. * @param * Implied path node implementation type. * * @return Node in the path holding a value of the given type. * * @see #getValueOfType(Class) Get the direct value of the parent node. */ @Nullable @SuppressWarnings("unchecked") default > I getPathOfType(@Nonnull Class type) { PathNode path = this; while (path != null) { if (type.isAssignableFrom(path.getValueType())) return (I) path; path = path.getParent(); } return null; } /** * @param type * Some type contained in the full path. * This includes the current {@link PathNode} and any {@link #getParent() parent}. * @param * Implied value type. * * @return Instance of value from the path, or {@code null} if not found in this path. * * @see #getPathOfType(Class) Get the containing {@link PathNode} instead of the direct value. */ @Nullable @SuppressWarnings("unchecked") default T getValueOfType(@Nonnull Class type) { PathNode path = this; while (path != null) { if (type.isAssignableFrom(path.getValueType())) return (T) path.getValue(); path = path.getParent(); } return null; } /** * @param type * Some type contained in the full path. * This includes the current {@link PathNode} and any {@link #getParent() parent}. * @param action * Action to run on the discovered value in the path. * If no value is found, the action is not run. * @param * Implied value type. * * @return {@code true} when a matching value was found and the action was run. */ default boolean onValue(@Nonnull Class type, @Nonnull Consumer action) { T value = getValueOfType(type); if (value != null) { action.accept(value); return true; } return false; } /** * @param type * Some {@link PathNode} type. * @param action * Action to run on the discovered path node. * If no path node is found, the action is not run. * @param * Implied path type. * * @return {@code true} when a matching path node was found and the action was run. */ @SuppressWarnings("unchecked") default > boolean onPath(@Nonnull Class type, @Nonnull Consumer action) { if (type == getClass()) { action.accept((T) this); return true; } else { PathNode parent = getParent(); if (parent != null) return parent.onPath(type, action); return false; } } /** * Checks for tree alignment. Consider this simple example: *

	 *   Path1   Path2   Path3
	 *     A       A       A
	 *     |       |       |
	 *     B       B       B
	 *     |       |       |
	 *     C       C       X
	 * 
* With this setup: *
    *
  • {@code path1C.allParentsMatch(path1C) == true} Self checks are equal
  • *
  • {@code path1C.allParentsMatch(path2C) == true} Two identical paths (by value of each node) are equal
  • *
  • {@code path1C.allParentsMatch(path2B) == false} Comparing between non-parallel levels are not equal
  • *
  • {@code path1C.allParentsMatch(path3X) == false} Paths to different items are not equal
  • *
* * @param other * Some other path node. * * @return {@code true} when from this level all parents going up the path match values. */ default boolean allParentsMatch(@Nonnull PathNode other) { // Type identifiers should match for all levels. if (!typeId().equals(other.typeId())) return false; // Should both have the same level of tree heights (number of parents). PathNode parent = getParent(); PathNode otherParent = other.getParent(); if (parent == null && otherParent == null) // Root node edge case return hasEqualOrChildValue(other); else if (parent == null || otherParent == null) // Mismatch in tree structure height return false; // Go up the chain if the matching values continue. if (hasEqualOrChildValue(other)) return parent.allParentsMatch(otherParent); return false; } /** * @param other * Some other path node. * * @return {@code true} when our path represents a more generic path than the given one. * {@code false} when our path does not belong to parent path of the given item. */ default boolean isParentOf(@Nonnull PathNode other) { return other.isDescendantOf(this); } /** * @param other * Some other path node. * * @return {@code true} when our path represents a more specific path than the given one. * {@code false} when our path does not belong to a potential sub-path of the given item. */ default boolean isDescendantOf(@Nonnull PathNode other) { // If our type identifiers are the same everything going up the path should match. String otherTypeId = other.typeId(); if (otherTypeId.equals(typeId())) return hasEqualOrChildValue(other) && allParentsMatch(other); // Check if the other is an allowed parent. PathNode parent = getParent(); if (directParentTypeIds().contains(otherTypeId) && parent != null) { // The parent is an allowed type, check if the parent says it is a descendant of the other path. if (parent == other || (parent.hasEqualOrChildValue(other))) return parent.isDescendantOf(other); } // Check in parent. if (parent != null) return parent.isDescendantOf(other); // Not a descendant. return false; } /** * @param o * Some other path node. * * @return Comparison for visual sorting purposes. */ int localCompare(PathNode o); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/path/PathNodes.java ================================================ package software.coley.recaf.path; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.objectweb.asm.tree.AbstractInsnNode; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.FileInfo; import software.coley.recaf.info.InnerClassInfo; import software.coley.recaf.info.annotation.AnnotationInfo; import software.coley.recaf.info.member.ClassMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.Bundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.Collections; import java.util.Set; /** * Utility methods for constructing paths. *

* Generally you should use this only when the path creation is * a "one time" action. For instance, if you want to make a path to a single method, use * {@link #memberPath(Workspace, WorkspaceResource, Bundle, ClassInfo, ClassMember)}. *

* However, if you want to make a path to all methods in a class then you would use * {@link #classPath(Workspace, WorkspaceResource, Bundle, ClassInfo)} to get a {@link ClassPathNode} * and then use {@link ClassPathNode#child(ClassMember)} for each member. This reduces the number of redundant * allocations of parent path node types in the chain. * * @author Matt Coley */ public class PathNodes { private PathNodes() { } /** * @param workspace * Workspace to wrap into path. * * @return Path to a workspace. */ @Nonnull public static WorkspacePathNode workspacePath(@Nonnull Workspace workspace) { return new WorkspacePathNode(workspace); } /** * @param workspace * Workspace to wrap into path. * @param resource * Resource to wrap into path. * * @return Path to resource. */ @Nonnull public static ResourcePathNode resourcePath(@Nonnull Workspace workspace, @Nonnull WorkspaceResource resource) { return workspacePath(workspace).child(resource); } /** * @param workspace * Workspace to wrap into path. * @param resource * Resource to wrap into path. * @param bundle * Bundle to wrap into path. * * @return Path to bundle. */ @Nonnull public static BundlePathNode bundlePath(@Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull Bundle bundle) { return resourcePath(workspace, resource).child(bundle); } /** * @param workspace * Workspace to wrap into path. * @param resource * Resource to wrap into path. * @param bundle * Bundle to wrap into path. * @param directory * Directory or package name to wrap into path. * * @return Path to directory or package (Depending on bundle type). */ @Nonnull public static DirectoryPathNode directoryPath(@Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull Bundle bundle, @Nullable String directory) { return bundlePath(workspace, resource, bundle).child(directory); } /** * @param workspace * Workspace to wrap into path. * @param resource * Resource to wrap into path. * @param bundle * Bundle to wrap into path. * @param cls * Class to wrap into path. * * @return Path to class. */ @Nonnull public static ClassPathNode classPath(@Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull Bundle bundle, @Nonnull ClassInfo cls) { return directoryPath(workspace, resource, bundle, cls.getPackageName()).child(cls); } /** * @param workspace * Workspace to wrap into path. * @param resource * Resource to wrap into path. * @param bundle * Bundle to wrap into path. * @param cls * Class to wrap into path. * @param member * Member to wrap into path. * * @return Path to class member (field or method). */ @Nonnull public static ClassMemberPathNode memberPath(@Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull Bundle bundle, @Nonnull ClassInfo cls, @Nonnull ClassMember member) { return classPath(workspace, resource, bundle, cls).child(member); } /** * @param workspace * Workspace to wrap into path. * @param resource * Resource to wrap into path. * @param bundle * Bundle to wrap into path. * @param cls * Class to wrap into path. * @param member * Member to wrap into path. * @param annotation * Annotation on member to wrap into path. * * @return Path to annotation on the member. */ @Nonnull public static AnnotationPathNode annotationPath(@Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull Bundle bundle, @Nonnull ClassInfo cls, @Nonnull ClassMember member, @Nonnull AnnotationInfo annotation) { return memberPath(workspace, resource, bundle, cls, member).childAnnotation(annotation); } /** * @param workspace * Workspace to wrap into path. * @param resource * Resource to wrap into path. * @param bundle * Bundle to wrap into path. * @param cls * Class to wrap into path. * @param method * Method to wrap into path. * @param variable * Variable in method to wrap into path. * * @return Path to variable in the method. */ @Nonnull public static LocalVariablePathNode variablePath(@Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull Bundle bundle, @Nonnull ClassInfo cls, @Nonnull MethodMember method, @Nonnull LocalVariable variable) { return memberPath(workspace, resource, bundle, cls, method).childVariable(variable); } /** * @param workspace * Workspace to wrap into path. * @param resource * Resource to wrap into path. * @param bundle * Bundle to wrap into path. * @param cls * Class to wrap into path. * @param method * Method to wrap into path. * @param insn * Instruction in method to wrap into path. * @param index * Index of the instruction within the method code. * * @return Path to instruction in the method. */ @Nonnull public static InstructionPathNode instructionPath(@Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull Bundle bundle, @Nonnull ClassInfo cls, @Nonnull MethodMember method, @Nonnull AbstractInsnNode insn, int index) { return memberPath(workspace, resource, bundle, cls, method).childInsn(insn, index); } /** * @param workspace * Workspace to wrap into path. * @param resource * Resource to wrap into path. * @param bundle * Bundle to wrap into path. * @param cls * Class to wrap into path. * @param method * Method to wrap into path. * @param thrownType * Type thrown by the method to wrap into path. * * @return Path to the thrown type on the method. */ @Nonnull public static ThrowsPathNode throwsPath(@Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull Bundle bundle, @Nonnull ClassInfo cls, @Nonnull MethodMember method, @Nonnull String thrownType) { return memberPath(workspace, resource, bundle, cls, method).childThrows(thrownType); } /** * @param workspace * Workspace to wrap into path. * @param resource * Resource to wrap into path. * @param bundle * Bundle to wrap into path. * @param cls * Class to wrap into path. * @param method * Method to wrap into path. * @param caughtType * Exception type caught by a {@code catch(T)} block to wrap into path. * * @return Path to any {@code catch(T)} block in the method of the given exception type. */ @Nonnull public static CatchPathNode catchPath(@Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull Bundle bundle, @Nonnull ClassInfo cls, @Nonnull MethodMember method, @Nonnull String caughtType) { return memberPath(workspace, resource, bundle, cls, method).childCatch(caughtType); } /** * @param workspace * Workspace to wrap into path. * @param resource * Resource to wrap into path. * @param bundle * Bundle to wrap into path. * @param cls * Class to wrap into path. * @param innerCls * Inner class to wrap into path. * * @return Path to inner class. */ @Nonnull public static InnerClassPathNode innerClassPath(@Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull Bundle bundle, @Nonnull ClassInfo cls, @Nonnull InnerClassInfo innerCls) { return classPath(workspace, resource, bundle, cls).child(innerCls); } /** * @param workspace * Workspace to wrap into path. * @param resource * Resource to wrap into path. * @param bundle * Bundle to wrap into path. * @param cls * Class to wrap into path. * @param annotation * Annotation on the class to wrap into path. * * @return Path to annotation on the class. */ @Nonnull public static AnnotationPathNode annotationPath(@Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull Bundle bundle, @Nonnull ClassInfo cls, @Nonnull AnnotationInfo annotation) { return classPath(workspace, resource, bundle, cls).child(annotation); } /** * @param workspace * Workspace to wrap into path. * @param resource * Resource to wrap into path. * @param bundle * Bundle to wrap into path. * @param file * File to wrap into path. * * @return Path to file. */ @Nonnull public static FilePathNode filePath(@Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull Bundle bundle, @Nonnull FileInfo file) { return directoryPath(workspace, resource, bundle, file.getDirectoryName()).child(file); } /** * @param identifier * Unique identifier for this path. * * @return Path of unique identifier. */ @Nonnull public static PathNode unique(@Nonnull String identifier) { return new ArbitraryStringPathNode(identifier); } /** * A path node that just holds an arbitrary string. *

* Intended for use in the UI where displayed panels are intended to be navigable for tracking, not actually * navigable in terms of their relationship to some location in a workspace. * * @see #unique(String) */ private static class ArbitraryStringPathNode extends AbstractPathNode { /** * @param value * Value instance. */ protected ArbitraryStringPathNode(@Nonnull String value) { super("uid", null, value); } @Nonnull @Override public Set directParentTypeIds() { return Collections.emptySet(); } @Override public int localCompare(PathNode o) { return 0; } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/path/ResourcePathNode.java ================================================ package software.coley.recaf.path; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.collections.Maps; import software.coley.collections.Unchecked; import software.coley.recaf.info.Named; import software.coley.recaf.util.CollectionUtils; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.Bundle; import software.coley.recaf.workspace.model.resource.WorkspaceFileResource; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; /** * Path node for {@link WorkspaceResource} types. * * @author Matt Coley */ public class ResourcePathNode extends AbstractPathNode { /** * Type identifier for annotation nodes. */ public static final String TYPE_ID = "resource"; /** * Node without parent. * * @param resource * Resource value. */ public ResourcePathNode(@Nonnull WorkspaceResource resource) { this((WorkspacePathNode) null, resource); } /** * Node with parent. * * @param parent * Parent node. * @param resource * Resource value. * * @see WorkspacePathNode#child(WorkspaceResource) */ public ResourcePathNode(@Nullable WorkspacePathNode parent, @Nonnull WorkspaceResource resource) { super(TYPE_ID, parent, resource); } /** * Node with parent. * * @param parent * Parent node. * @param resource * Resource value. * * @see WorkspacePathNode#child(WorkspaceResource) */ public ResourcePathNode(@Nullable EmbeddedResourceContainerPathNode parent, @Nonnull WorkspaceResource resource) { super(TYPE_ID, parent, resource); } /** * @param bundle * Bundle to wrap into node. * * @return Path node of bundle, with the current resource as parent. */ @Nonnull public BundlePathNode child(@Nonnull Bundle bundle) { return new BundlePathNode(this, bundle); } /** * @return Path node for a container of multiple embedded resources. */ @Nonnull public EmbeddedResourceContainerPathNode embeddedChildContainer() { Workspace valueOfType = Objects.requireNonNull(getValueOfType(Workspace.class), "Path did not contain workspace in parent"); return new EmbeddedResourceContainerPathNode(this, valueOfType); } /** * @return {@code true} when this resource node, wraps the primary resource of a workspace. */ public boolean isPrimary() { PathNode parent = getParent(); if (parent == null) return false; return parent.getValue().getPrimaryResource() == getValue(); } /** * @return {@code true} when this resource node, wraps the primary resource of a workspace or any resource embedded in the primary resource. */ public boolean isPrimaryOrEmbeddedInPrimary() { PathNode parent = getParent(); if (parent == null) return false; Workspace workspace = parent.getValue(); WorkspaceResource primary = workspace.getPrimaryResource(); WorkspaceResource resource = getValue(); while (resource != null) { if (primary == resource) return true; resource = resource.getContainingResource(); } return false; } @Nonnull @Override public Set directParentTypeIds() { return Set.of(WorkspacePathNode.TYPE_ID); } @Override public int localCompare(PathNode o) { if (this == o) return 0; if (o instanceof ResourcePathNode resourcePathNode) { PathNode parent = getParent(); Workspace workspace = parentValue(); WorkspaceResource resource = getValue(); WorkspaceResource otherResource = resourcePathNode.getValue(); if (parent instanceof EmbeddedResourceContainerPathNode) { PathNode parentOfParent = Unchecked.cast(parent.getParent()); Map lookup = Maps.reverse(parentOfParent.getValue().getEmbeddedResources()); String ourKey = lookup.getOrDefault(resource, "?"); String otherKey = lookup.getOrDefault(otherResource, "?"); return Named.STRING_PATH_COMPARATOR.compare(ourKey, otherKey); } else { if (workspace != null) { if (resource == otherResource) return 0; // Show in order as in the workspace. List resources = workspace.getAllResources(false); return Integer.compare(CollectionUtils.identityIndexOf(resources, resource), CollectionUtils.identityIndexOf(resources, otherResource)); } else { // Enforce some ordering. Not ideal but works. return Named.STRING_COMPARATOR.compare( resource.getClass().getSimpleName(), otherResource.getClass().getSimpleName() ); } } } return 0; } @Override public boolean equals(Object o) { // Resource equality checks are abysmally slow because are checking if all the contained // contents are also equal. Realistically we can get away with a reference check. if (o instanceof ResourcePathNode otherPath) return getValue() == otherPath.getValue() && Objects.equals(getParent(), otherPath.getParent()); return false; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/path/ThrowsPathNode.java ================================================ package software.coley.recaf.path; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.member.ClassMember; import software.coley.recaf.info.member.MethodMember; import java.util.Set; /** * Path node for {@code throws} on {@link MethodMember} instances. * * @author Matt Coley */ public class ThrowsPathNode extends AbstractPathNode { /** * Type identifier for throws nodes. */ public static final String TYPE_ID = "throws"; /** * Node without parent. * * @param type * Thrown type. */ public ThrowsPathNode(@Nonnull String type) { this(null, type); } /** * Node with parent. * * @param parent * Parent node. * @param type * Thrown type. * * @see ClassMemberPathNode#childThrows(String) */ public ThrowsPathNode(@Nullable ClassMemberPathNode parent, @Nonnull String type) { super(TYPE_ID, parent, type); } @Override public ClassMemberPathNode getParent() { return (ClassMemberPathNode) super.getParent(); } @Nonnull @Override public Set directParentTypeIds() { return Set.of(ClassMemberPathNode.TYPE_ID); } @Override public int localCompare(PathNode o) { if (this == o) return 0; if (o instanceof ThrowsPathNode node) return getValue().compareTo(node.getValue()); else if (o instanceof LocalVariablePathNode || o instanceof InstructionPathNode || o instanceof CatchPathNode) return -1; return 0; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/path/WorkspacePathNode.java ================================================ package software.coley.recaf.path; import jakarta.annotation.Nonnull; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.Collections; import java.util.Set; /** * Path node for {@link Workspace} types. * * @author Matt Coley */ public class WorkspacePathNode extends AbstractPathNode { /** * Type identifier for workspace nodes. */ public static final String TYPE_ID = "workspace"; /** * Node without parent. * * @param value * Workspace value. */ public WorkspacePathNode(@Nonnull Workspace value) { super(TYPE_ID, null, value); } /** * @param resource * Resource to wrap into node. * * @return Path node of resource, with the current workspace as parent, * or a {@link EmbeddedResourceContainerPathNode} if the passed resource is an embedded resource. */ @Nonnull public ResourcePathNode child(@Nonnull WorkspaceResource resource) { // Base case, resource is top-level in the workspace. WorkspaceResource containingResource = resource.getContainingResource(); if (containingResource == null) return new ResourcePathNode(this, resource); // Resource is embedded, so we need to represent the path a bit differently. // - Note: We flatten the representation of embedded resources here. WorkspaceResource rootResource = containingResource; while (rootResource.getContainingResource() != null) rootResource = rootResource.getContainingResource(); return new ResourcePathNode(this, rootResource) .embeddedChildContainer() .child(resource); } @Nonnull @Override public Set directParentTypeIds() { return Collections.emptySet(); } @Override public boolean isDescendantOf(@Nonnull PathNode other) { // Workspace is the root of all paths. // Only other workspace paths with the same value should count here. if (typeId().equals(other.typeId())) return getValue().equals(other.getValue()); // We have no parents. return false; } @Override public int localCompare(PathNode o) { return 0; } @Override public boolean equals(Object o) { // Workspace equality checks are abysmally slow because are checking if all the contained // contents are also equal. Realistically we can get away with a reference check. if (o instanceof WorkspacePathNode otherPath) return getValue() == otherPath.getValue(); return false; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/plugin/Plugin.java ================================================ package software.coley.recaf.plugin; /** * Base interface that all plugins must inherit from. * Classes that implement this type should also be annotated with {@link PluginInformation}. * * @author xDark */ public interface Plugin { /** * Called when plugin is being enabled. */ void onEnable(); /** * Called when plugin is being disabled. */ void onDisable(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/plugin/PluginInformation.java ================================================ package software.coley.recaf.plugin; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * Plugin annotation containing necessary information. * * @author xDark */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface PluginInformation { /** * @return ID of the plugin. */ String id(); /** * @return Name of the plugin. */ String name(); /** * @return Version of the plugin. */ String version(); /** * @return Author of the plugin. */ String author() default ""; /** * @return Description of the plugin. */ String description() default ""; /** * @return Plugin dependencies (IDs of dependency plugins). */ String[] dependencies() default {}; /** * @return Plugin soft dependencies (IDs of dependency plugins). */ String[] softDependencies() default {}; } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/Service.java ================================================ package software.coley.recaf.services; import jakarta.annotation.Nonnull; /** * Outline of a service. * * @author Matt Coley */ public interface Service { /** * @return A unique string for identifying the service. */ @Nonnull String getServiceId(); /** * @return The config instance for the service. */ @Nonnull ServiceConfig getServiceConfig(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/ServiceConfig.java ================================================ package software.coley.recaf.services; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.config.ConfigContainer; /** * Base type for a service's config type. * When a service implementation uses {@link Inject} to inject the config instance some rules should be followed: *

    *
  • The type injected is the implementation type, not {@link ServiceConfig}
  • *
  • The config implementation type is annotated with {@link ApplicationScoped} so that it is shared * among all instances of a service.
  • *
* * @author Matt Coley */ public interface ServiceConfig extends ConfigContainer { } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/assembler/AbstractAssemblerPipeline.java ================================================ package software.coley.recaf.services.assembler; import jakarta.annotation.Nonnull; import me.darknet.assembler.ast.ASTElement; import me.darknet.assembler.ast.ElementType; import me.darknet.assembler.compiler.ClassRepresentation; import me.darknet.assembler.compiler.ClassResult; import me.darknet.assembler.compiler.Compiler; import me.darknet.assembler.compiler.CompilerOptions; import me.darknet.assembler.compiler.InheritanceChecker; import me.darknet.assembler.error.Error; import me.darknet.assembler.error.Result; import me.darknet.assembler.printer.AnnotationHolder; import me.darknet.assembler.printer.AnnotationPrinter; import me.darknet.assembler.printer.ClassPrinter; import me.darknet.assembler.printer.PrintContext; import me.darknet.assembler.printer.Printer; import software.coley.observables.AbstractObservable; import software.coley.observables.ChangeListener; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.annotation.Annotated; import software.coley.recaf.info.annotation.AnnotationInfo; import software.coley.recaf.info.member.ClassMember; import software.coley.recaf.path.AnnotationPathNode; import software.coley.recaf.path.ClassMemberPathNode; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.path.PathNode; import software.coley.recaf.workspace.model.Workspace; import java.util.List; /** * Common pipeline implementation details for all class types. * * @param * Class type which will be assembled. * @param * Compile return value for JASM {@link Compiler}. * @param * Class intermediate representation type. * * @author Justus Garbe */ public abstract class AbstractAssemblerPipeline implements AssemblerPipeline { protected final AssemblerPipelineConfig pipelineConfig; private final AssemblerPipelineGeneralConfig generalConfig; private final ListenerHost indentListener = new ListenerHost(); protected PrintContext context; public AbstractAssemblerPipeline(@Nonnull AssemblerPipelineGeneralConfig generalConfig, @Nonnull AssemblerPipelineConfig pipelineConfig) { this.generalConfig = generalConfig; this.pipelineConfig = pipelineConfig; generalConfig.getDisassemblyIndent().addChangeListener(indentListener); } private void refreshContext() { context = new PrintContext<>(generalConfig.getDisassemblyIndent().getValue()); // 10000000000 vs 1E10 if (generalConfig.getUseWholeFloatingNumbers().getValue()) context.setForceWholeNumberRepresentation(true); // Enable comments that outline where try-catch ranges begin/end. if (pipelineConfig instanceof JvmAssemblerPipelineConfig jvmConfig && jvmConfig.emitTryRangeComments()) context.setDebugTryCatchRanges(true); } @Nonnull protected abstract Result classPrinter(@Nonnull ClassPathNode path); @Nonnull protected abstract CompilerOptions> getCompilerOptions(); @Nonnull protected abstract Compiler getCompiler(); @Nonnull protected abstract InheritanceChecker getInheritanceChecker(); protected abstract int getClassVersion(@Nonnull C info); @Nonnull @SuppressWarnings("unchecked") protected Result compile(@Nonnull List elements, @Nonnull PathNode path) { if (elements.isEmpty()) { return Result.err(Error.of("No elements to compile", null)); } if (elements.size() != 1) { return Result.err(Error.of("Multiple elements to compile", elements.get(1).location())); } ASTElement element = elements.get(0); if (element == null) { return Result.err(Error.of("No element to compile", null)); } ClassInfo classInfo = path.getValueOfType(ClassInfo.class); if (classInfo == null) { return Result.err(Error.of("Dangling member", null)); } C info = (C) classInfo; CompilerOptions> options = getCompilerOptions(); options.version(getClassVersion(info)) .inheritanceChecker(getInheritanceChecker()); if (element.type() != ElementType.CLASS) { options.overlay(getRepresentation(info)); if (element.type() == ElementType.ANNOTATION) { // build annotation path String annoPath = "this"; PathNode parent = path.getParent(); if (parent instanceof ClassMemberPathNode memberPathNode) { annoPath += memberPathNode.isMethod() ? ".method." : ".field."; annoPath += memberPathNode.getValue().getName() + "."; annoPath += memberPathNode.getValue().getDescriptor(); } Annotated annotated = path.getValueOfType(Annotated.class); if (annotated == null) { return Result.err(Error.of("Dangling annotation", null)); } AnnotationInfo annotation = (AnnotationInfo) path.getValue(); annoPath += annotated.getAnnotations().indexOf(annotation); options.annotationPath(annoPath); } } Compiler compiler = getCompiler(); return (Result) compiler.compile(elements, options); } @Nonnull protected Result memberPrinter(@Nonnull ClassMemberPathNode path) { ClassPathNode owner = path.getParent(); if (owner == null) return Result.err(Error.of("Dangling member", null)); ClassMember member = path.getValue(); return classPrinter(owner).flatMap((printer) -> { Printer memberPrinter = null; if (member.isMethod()) { memberPrinter = printer.method(member.getName(), member.getDescriptor()); } else if (member.isField()) { memberPrinter = printer.field(member.getName(), member.getDescriptor()); } if (memberPrinter == null) { return Result.err(Error.of("Failed to find member", null)); } else { return Result.ok(memberPrinter); } }); } @Nonnull protected Result annotationPrinter(@Nonnull AnnotationPathNode path) { if (path.getParent() == null) { return Result.err(Error.of("Dangling annotation", null)); } Object parent = path.getParent().getValue(); Result parentPrinter; if (parent instanceof ClassPathNode classNode) { parentPrinter = classPrinter(classNode); } else if (parent instanceof ClassMemberPathNode classMember) { parentPrinter = memberPrinter(classMember); } else { return Result.err(Error.of("Invalid parent type", null)); } AnnotationInfo annotation = path.getValue(); if (parent instanceof Annotated annotated) { return parentPrinter.flatMap((printer) -> { if (printer instanceof AnnotationHolder holder) { return Result.ok(holder.annotation(annotated.getAnnotations().indexOf(annotation))); } else { return Result.err(Error.of("Parent is not an annotation holder", null)); } }); } else { return Result.err(Error.of("Parent cannot hold annotations", null)); } } @Nonnull protected String print(@Nonnull Printer printer) { refreshContext(); // new context printer.print(context); return context.toString(); } /** * Called when the associated {@link Workspace} for this pipeline is closed. */ public void close() { generalConfig.getDisassemblyIndent().removeChangeListener(indentListener); } @Nonnull @Override public AssemblerPipelineConfig getConfig() { return pipelineConfig; } private class ListenerHost implements ChangeListener { @Override public void changed(AbstractObservable abstractObservable, String s, String t1) { refreshContext(); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/assembler/AndroidAssemblerPipeline.java ================================================ package software.coley.recaf.services.assembler; import jakarta.annotation.Nonnull; /** * Dalvik assembler pipeline implementation. * * @author Matt Coley */ public class AndroidAssemblerPipeline { public static final String SERVICE_ID = "dalvik-assembler"; public AndroidAssemblerPipeline(@Nonnull AssemblerPipelineGeneralConfig generalConfig, @Nonnull AndroidAssemblerPipelineConfig androidConfig) { // TODO: Implement when dalvik assembler pipeline is implemented } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/assembler/AndroidAssemblerPipelineConfig.java ================================================ package software.coley.recaf.services.assembler; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.observables.ObservableBoolean; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.BasicConfigValue; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link AndroidAssemblerPipeline}. * * @author Matt Coley */ @ApplicationScoped public class AndroidAssemblerPipelineConfig extends BasicConfigContainer implements ServiceConfig, AssemblerPipelineConfig { private final ObservableBoolean valueAnalysis = new ObservableBoolean(true); private final ObservableBoolean simulateJvmCalls = new ObservableBoolean(true); @Inject public AndroidAssemblerPipelineConfig() { super(ConfigGroups.SERVICE_ASSEMBLER, AndroidAssemblerPipeline.SERVICE_ID + CONFIG_SUFFIX); addValue(new BasicConfigValue<>("value-analysis", boolean.class, valueAnalysis)); addValue(new BasicConfigValue<>("simulate-jvm-calls", boolean.class, simulateJvmCalls)); } @Override public boolean isValueAnalysisEnabled() { return valueAnalysis.getValue(); } @Override public boolean isSimulatingCommonJvmCalls() { return simulateJvmCalls.hasValue(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/assembler/AssemblerPipeline.java ================================================ package software.coley.recaf.services.assembler; import jakarta.annotation.Nonnull; import me.darknet.assembler.ast.ASTElement; import me.darknet.assembler.compiler.ClassRepresentation; import me.darknet.assembler.compiler.ClassResult; import me.darknet.assembler.compiler.Compiler; import me.darknet.assembler.error.Error; import me.darknet.assembler.error.Result; import me.darknet.assembler.parser.DeclarationParser; import me.darknet.assembler.parser.ParsingResult; import me.darknet.assembler.parser.Token; import me.darknet.assembler.parser.Tokenizer; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.path.AnnotationPathNode; import software.coley.recaf.path.ClassMemberPathNode; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.path.PathNode; import java.util.List; /** * Assembler pipeline outline. * * @param * Class type which will be assembled. * @param * Compile return value for JASM {@link Compiler}. * @param * Class intermediate representation type. * * @author Justus Garbe */ public interface AssemblerPipeline { /** * @param input * The text to tokenize. * @param source * Identifier of where the text originates from. * * @return Result wrapping a list of tokens on successful tokenization. * Result wrapping a list of tokenization errors otherwise. */ @Nonnull default Result> tokenize(@Nonnull String input, @Nonnull String source) { return new Tokenizer().tokenize(source, input); } /** * Parses only AST declarations. The contents of those declarations are not parsed. * You will want to pass this result into {@link #concreteParse(List)} to get all * contents parsed. *
* Alternatively you can use {@link #fullParse(List)} which does these two steps for you. * * @param tokens * Tokens to parse into a series of AST elements. * * @return Result wrapping the partial constructed AST elements on successful parsing. * Result wrapping a list of parse errors otherwise. * * @see #concreteParse(List) Second step to fully parse AST elements. * @see #fullParse(List) Full parse so that you do not have to do the two-step system yourself. */ @Nonnull default ParsingResult> roughParse(@Nonnull List tokens) { return new DeclarationParser().parseDeclarations(tokens); } /** * The second step used after {@link #roughParse(List)}. * * @param elements * Declaration elements to complete parsing of. * * @return Result wrapping fully constructed AST elements on successful parsing. * Result wrapping a list of parse errors otherwise. */ @Nonnull Result> concreteParse(@Nonnull List elements); /** * The full parse operation if you do not want to call both {@link #roughParse(List)} and * {@link #concreteParse(List)} individually. * * @param tokens * Tokens to parse into a series of AST elements. * * @return Result wrapping fully constructed AST elements on successful parsing. * Result wrapping a list of parse errors otherwise. */ @Nonnull default Result> fullParse(@Nonnull List tokens) { var result = roughParse(tokens); if (result.isOk()) { return concreteParse(result.get()); } else { return result; } } /** * Takes a list of AST elements, assumed to be fully parsed, and assembles it to the target class type. * * @param elements * List of AST elements representing a class to construct into a class. * @param path * Path to the expected class destination in the workspace. * * @return Result wrapping the assembled class on successful assembling. * Result wrapping a list of assemble errors otherwise. */ @Nonnull Result assemble(@Nonnull List elements, @Nonnull PathNode path); /** * Takes a list of AST elements, assumed to be fully parsed, and assembles it to the target class type. * * @param elements * List of AST elements representing a class to construct into a class. * @param path * Path to the expected class destination in the workspace. * * @return Result wrapping the assembled class on successful assembling. * Result wrapping a list of assemble errors otherwise. */ @Nonnull default Result assembleAndWrap(@Nonnull List elements, @Nonnull PathNode path) { return assemble(elements, path) .flatMap(r -> Result.ok(getClassInfo((I) r.representation()))); } /** * @param path * Path to class to disassemble. * * @return Result wrapping the disassembled class on successful disassembling. * Result wrapping disassemble errors otherwise. */ @Nonnull Result disassemble(@Nonnull ClassPathNode path); /** * @param path * Path to a field or method to disassemble. * * @return Result wrapping the disassembled field/method on successful disassembling. * Result wrapping disassemble errors otherwise. */ @Nonnull Result disassemble(@Nonnull ClassMemberPathNode path); /** * @param path * Path to annotation to disassemble. * * @return Result wrapping the disassembled annotation on successful disassembling. * Result wrapping disassemble errors otherwise. */ @Nonnull Result disassemble(@Nonnull AnnotationPathNode path); /** * @param path * Path to some item that can be disassembled. * * @return Result wrapping the disassembled item on successful disassembling. * Result wrapping disassemble errors otherwise. */ @Nonnull default Result disassemble(@Nonnull PathNode path) { if (path instanceof ClassPathNode classPathNode) return disassemble(classPathNode); if (path instanceof ClassMemberPathNode classMemberPathNode) return disassemble(classMemberPathNode); if (path instanceof AnnotationPathNode annotationPathNode) return disassemble(annotationPathNode); return Result.err(Error.of("Unsupported node type: " + path.getClass().getName(), null)); } /** * @param info * Class info to convert into the intermediate representation format. * * @return IR. */ @Nonnull I getRepresentation(@Nonnull C info); /** * @param representation * Intermediate representation format to map into Recaf's class info type. * * @return Class info. */ @Nonnull C getClassInfo(@Nonnull I representation); /** * @return Pipeline's specific config. */ @Nonnull AssemblerPipelineConfig getConfig(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/assembler/AssemblerPipelineConfig.java ================================================ package software.coley.recaf.services.assembler; import software.coley.recaf.config.ConfigContainer; /** * Assembler pipeline config outline. * * @author Justus Garbe * @see AndroidAssemblerPipelineConfig * @see JvmAssemblerPipelineConfig */ public interface AssemblerPipelineConfig extends ConfigContainer { /** * @return {@code true} when the assembler's analyzer should use more detailed frame models which include * values for primitives and strings where possible. {@code false} to only track the expected type of values * and nothing else. */ boolean isValueAnalysisEnabled(); /** * Requires {@link #isValueAnalysisEnabled()} be {@code true}. * * @return {@code true} to enhance value enabled analysis with the ability to look up values of fields and methods * of known calls. Usually this is for supplying constants like {@link Integer#MAX_VALUE} and such to the analyzer. * {@code false} to disable value content simulation for all field and method references. */ boolean isSimulatingCommonJvmCalls(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/assembler/AssemblerPipelineGeneralConfig.java ================================================ package software.coley.recaf.services.assembler; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.observables.ObservableBoolean; import software.coley.observables.ObservableInteger; import software.coley.observables.ObservableString; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.BasicConfigValue; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Common config for all assemblers. * * @author Justus Garbe */ @ApplicationScoped public class AssemblerPipelineGeneralConfig extends BasicConfigContainer implements ServiceConfig { private final ObservableString disassemblyIndent = new ObservableString(" "); private final ObservableInteger disassemblyAstParseDelay = new ObservableInteger(100); private final ObservableBoolean useWholeFloatingNumbers = new ObservableBoolean(true); @Inject public AssemblerPipelineGeneralConfig() { super(ConfigGroups.SERVICE_ASSEMBLER, AssemblerPipelineManager.SERVICE_ID + ConfigGroups.PACKAGE_SPLIT + "general" + CONFIG_SUFFIX); addValue(new BasicConfigValue<>("disassembly-indent", String.class, disassemblyIndent)); addValue(new BasicConfigValue<>("disassembly-ast-parse-delay", int.class, disassemblyAstParseDelay)); addValue(new BasicConfigValue<>("disassembly-whole-floating", boolean.class, useWholeFloatingNumbers)); } /** * @return String of a single indentation level. */ @Nonnull public ObservableString getDisassemblyIndent() { return disassemblyIndent; } /** * @return Delay between each parse operation. */ @Nonnull public ObservableInteger getDisassemblyAstParseDelay() { return disassemblyAstParseDelay; } /** * @return {@code true} to prefer {@code 10000000000} over {@link 1e10}. */ @Nonnull public ObservableBoolean getUseWholeFloatingNumbers() { return useWholeFloatingNumbers; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/assembler/AssemblerPipelineManager.java ================================================ package software.coley.recaf.services.assembler; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import me.darknet.assembler.compiler.ClassRepresentation; import me.darknet.assembler.compiler.ClassResult; import software.coley.recaf.cdi.EagerInitialization; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.path.PathNode; import software.coley.recaf.services.Service; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.services.inheritance.InheritanceGraphService; import software.coley.recaf.services.workspace.WorkspaceCloseListener; import software.coley.recaf.services.workspace.WorkspaceManager; import software.coley.recaf.services.workspace.WorkspaceOpenListener; import software.coley.recaf.workspace.model.EmptyWorkspace; import software.coley.recaf.workspace.model.Workspace; import java.util.Objects; /** * Assembler implementations manager. * * @author Justus Garbe */ @EagerInitialization @ApplicationScoped public class AssemblerPipelineManager implements Service { public static final String SERVICE_ID = "assembler-pipeline"; private final WorkspaceManager workspaceManager; private final InheritanceGraphService graphService; private final JvmAssemblerPipelineConfig jvmConfig; private final AndroidAssemblerPipelineConfig androidConfig; private final AssemblerPipelineGeneralConfig config; private JvmAssemblerPipeline currentJvmPipeline; @Inject public AssemblerPipelineManager(@Nonnull WorkspaceManager workspaceManager, @Nonnull InheritanceGraphService graphService, @Nonnull AssemblerPipelineGeneralConfig config, @Nonnull AndroidAssemblerPipelineConfig androidConfig, @Nonnull JvmAssemblerPipelineConfig jvmConfig) { this.workspaceManager = workspaceManager; this.graphService = graphService; this.config = config; this.jvmConfig = jvmConfig; this.androidConfig = androidConfig; ListenerHost host = new ListenerHost(); workspaceManager.addWorkspaceOpenListener(host); workspaceManager.addWorkspaceCloseListener(host); } /** * Automatically pick a pipeline for the content in the given path. * * @param path * Path to some item in the workspace to get an assembler pipeline for. * * @return Either a {@link JvmAssemblerPipeline} or {@link AndroidAssemblerPipeline} based on the path contents. */ @Nonnull public AssemblerPipeline getPipeline(@Nonnull PathNode path) { ClassInfo info = path.getValueOfType(ClassInfo.class); if (info == null) throw new IllegalStateException("Failed to find class info for node: " + path); if (info.isJvmClass()) { Workspace workspace = Objects.requireNonNullElseGet(path.getValueOfType(Workspace.class), EmptyWorkspace::get); return newJvmAssemblerPipeline(workspace); } else { // TODO: Implement when dalvik assembler pipeline is implemented throw new UnsupportedOperationException("Dalvik assembler pipeline is not implemented"); } } /** * @param workspace * Workspace to pull class data from. * * @return Assembler pipeline for JVM classes. */ @Nonnull public JvmAssemblerPipeline newJvmAssemblerPipeline(@Nonnull Workspace workspace) { if (currentJvmPipeline != null && workspace == workspaceManager.getCurrent()) return currentJvmPipeline; InheritanceGraph graph = graphService.getOrCreateInheritanceGraph(workspace); return new JvmAssemblerPipeline(workspace, Objects.requireNonNull(graph), config, jvmConfig); } /** * @param workspace * Workspace to pull class data from. * * @return Assembler pipeline for Dalvik classes. */ @Nonnull public AndroidAssemblerPipeline newAndroidAssemblerPipeline(@Nonnull Workspace workspace) { return new AndroidAssemblerPipeline(config, androidConfig); } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public AssemblerPipelineGeneralConfig getServiceConfig() { return config; } private class ListenerHost implements WorkspaceOpenListener, WorkspaceCloseListener { @Override public void onWorkspaceOpened(@Nonnull Workspace workspace) { currentJvmPipeline = newJvmAssemblerPipeline(workspace); } @Override public void onWorkspaceClosed(@Nonnull Workspace workspace) { if (currentJvmPipeline != null) { currentJvmPipeline.close(); currentJvmPipeline = null; } } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/assembler/ExpressionCompileException.java ================================================ package software.coley.recaf.services.assembler; import jakarta.annotation.Nonnull; /** * Exception encompassing problems ocurring during the compilation of an expression all via {@link ExpressionCompiler}. * * @author Matt Coley */ public class ExpressionCompileException extends Exception { /** * @param message * Error message. */ public ExpressionCompileException(@Nonnull String message) { super(message); } /** * @param cause * The cause of the exception. * @param message * Error message. */ public ExpressionCompileException(@Nonnull Throwable cause, @Nonnull String message) { super(message, cause); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/assembler/ExpressionCompiler.java ================================================ package software.coley.recaf.services.assembler; import dev.xdark.blw.type.MethodType; import dev.xdark.blw.type.Types; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import jakarta.inject.Inject; import me.darknet.assembler.printer.JvmClassPrinter; import me.darknet.assembler.printer.MethodPrinter; import me.darknet.assembler.printer.PrintContext; import org.objectweb.asm.Opcodes; import org.slf4j.Logger; import regexodus.Pattern; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.info.InnerClassInfo; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.services.compile.CompilerDiagnostic; import software.coley.recaf.services.compile.CompilerResult; import software.coley.recaf.services.compile.JavacArguments; import software.coley.recaf.services.compile.JavacCompiler; import software.coley.recaf.services.compile.stub.ExpressionHostingClassStubGenerator; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.services.inheritance.InheritanceGraphService; import software.coley.recaf.services.workspace.WorkspaceManager; import software.coley.recaf.util.AccessFlag; import software.coley.recaf.util.JavaVersion; import software.coley.recaf.util.NumberUtil; import software.coley.recaf.util.RegexUtil; import software.coley.recaf.util.StringUtil; import software.coley.recaf.workspace.model.Workspace; import java.io.ByteArrayInputStream; import java.io.IOException; import java.util.Collections; import java.util.List; import java.util.Objects; /** * Compiles Java source expressions into JASM. * * @author Matt Coley */ @Dependent public class ExpressionCompiler { private static final Logger logger = Logging.get(ExpressionCompiler.class); private static final Pattern IMPORT_EXTRACT_PATTERN = RegexUtil.pattern("^\\s*(import \\w.+;)"); public static final String EXPR_MARKER = "/* EXPR_START */"; private final JavacCompiler javac; private final Workspace workspace; private final AssemblerPipelineGeneralConfig assemblerConfig; private final InheritanceGraph inheritanceGraph; private int classAccess; private String className; private String superName; private List implementing; private int versionTarget; private List fields; private List methods; private List innerClasses; private String methodName; private MethodType methodType; private int methodFlags; private List methodVariables; @Inject public ExpressionCompiler(@Nonnull WorkspaceManager workspaceManager, @Nonnull InheritanceGraphService inheritanceGraphService, @Nonnull JavacCompiler javac, @Nonnull AssemblerPipelineGeneralConfig assemblerConfig) { this.workspace = Objects.requireNonNull(workspaceManager.getCurrent(), "No open workspace"); this.inheritanceGraph = inheritanceGraphService.getCurrentWorkspaceInheritanceGraph(); this.javac = javac; this.assemblerConfig = assemblerConfig; clearContext(); } /** * Resets the assembler to have no class or method context. */ public void clearContext() { className = "RecafExpression"; classAccess = 0; superName = null; implementing = Collections.emptyList(); versionTarget = JavaVersion.get(); fields = Collections.emptyList(); methods = Collections.emptyList(); innerClasses = Collections.emptyList(); methodName = "generated"; methodType = Types.methodType("()V"); methodFlags = Opcodes.ACC_STATIC | Opcodes.ACC_BRIDGE; // Bridge used to denote default state. methodVariables = Collections.emptyList(); } /** * Updates the expression compiler to create the expression within the given class. * This allows access to the class's fields, methods, and type hierarchy. * * @param classInfo * Class to pull info from. */ public void setClassContext(@Nonnull JvmClassInfo classInfo) { String type = classInfo.getName(); String superType = classInfo.getSuperName(); className = type; classAccess = classInfo.getAccess(); versionTarget = NumberUtil.intClamp(classInfo.getVersion() - JvmClassInfo.BASE_VERSION, JavacCompiler.getMinTargetVersion(), JavaVersion.get()); superName = classInfo.getSuperName(); implementing = classInfo.getInterfaces(); fields = classInfo.getFields(); methods = classInfo.getMethods(); innerClasses = classInfo.getInnerClasses(); // We use bridge to denote that the default flags are set. // When we assign a class, there may be non-static fields/methods the user will want to interact with. // Thus, we should clear our flags from the default so that they can do that. if (AccessFlag.isBridge(methodFlags)) methodFlags = 0; // TODO: Support for generics (For example, if we implement Supplier and we have a method "String get()") // - Also will want per-method signatures for things like 'List strings' as a parameter // - https://docs.oracle.com/javase/specs/jvms/se9/html/jvms-4.html#jvms-4.7.9.1 } /** * Updates the expression compiler to create the expression within the given method. * This allows access to the method's parameters. * * @param method * Method to pull info from. */ public void setMethodContext(@Nonnull MethodMember method) { methodName = method.getName(); methodType = Types.methodType(method.getDescriptor()); methodFlags = method.getAccess(); methodVariables = method.getLocalVariables(); } /** * Set the target version of Java. *
* Java 8 would pass 8. * * @param versionTarget * Java version target. * Range of supported values: [{@link JavacCompiler#getMinTargetVersion()} - {@link JavaVersion#get()}] */ public void setVersionTarget(int versionTarget) { this.versionTarget = versionTarget; } /** * Compiles the given expression with the current context. * * @param expression * Expression to compile. * * @return Expression compilation result. * * @see #setClassContext(JvmClassInfo) For allowing access to a class's fields/methods/inheritance. * @see #setMethodContext(MethodMember) For allowing access to a method's parameters & other local variables. */ @Nonnull public ExpressionResult compile(@Nonnull String expression) { // Generate source of a class to house the expression within ExpressionHostingClassStubGenerator stubber; String code; try { stubber = new ExpressionHostingClassStubGenerator(workspace, inheritanceGraph, classAccess, className, superName, implementing, fields, methods, innerClasses, methodFlags, methodName, methodType, methodVariables, expression); code = stubber.generate(); } catch (ExpressionCompileException ex) { return new ExpressionResult(ex); } // Compile the generated class JavacArguments arguments = new JavacArguments(className, code, null, Math.max(versionTarget, JavacCompiler.getMinTargetVersion()), -1, true, false, false); CompilerResult result = javac.compile(arguments, workspace, null); if (!result.wasSuccess()) { Throwable exception = result.getException(); if (exception != null) return new ExpressionResult(new ExpressionCompileException(exception, "Compilation task encountered an error")); List diagnostics = result.getDiagnostics(); if (!diagnostics.isEmpty()) return new ExpressionResult(remap(code, diagnostics)); } byte[] klass = result.getCompilations().get(className); if (klass == null) return new ExpressionResult(new ExpressionCompileException("Compilation results missing the generated expression class")); // Convert the compiled class to JASM try { PrintContext context = new PrintContext<>(assemblerConfig.getDisassemblyIndent().getValue()); context.setLabelPrefix("g"); JvmClassPrinter printer = new JvmClassPrinter(new ByteArrayInputStream(klass)); MethodPrinter method = printer.method(stubber.getAdaptedMethodName(), stubber.methodDescriptorWithVariables()); if (method == null) return new ExpressionResult(new ExpressionCompileException("Target method was not in generated class")); method.print(context); return new ExpressionResult(context.toString()); } catch (IOException ex) { return new ExpressionResult(new ExpressionCompileException(ex, "Failed to print generated class")); } catch (ExpressionCompileException ex) { return new ExpressionResult(ex); } } /** * @param code * Generateed code to work with. * @param diagnostics * Compiler diagnostics affecting the given code. * * @return Diagnostics mapped to the original expression lines, rather than the lines in the full generated code. */ @Nonnull private static List remap(@Nonnull String code, @Nonnull List diagnostics) { // Given the following example code: // // 1: package foo; // 2: class Foo extends Bar { // 3: void method() { /* EXPR_START */ // 4: // Code here // // The expression marker is on line 3, and our code starts on line four. So the reported line numbers need to // be shifted down by three. There are two line breaks between the start and the marker, so we add plus one // to consider the line the marker is itself on. int exprStart = code.indexOf(EXPR_MARKER); int lineOffset = StringUtil.count('\n', code.substring(0, exprStart)) + 1; return diagnostics.stream() .map(d -> d.withLine(d.line() - lineOffset)) .toList(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/assembler/ExpressionResult.java ================================================ package software.coley.recaf.services.assembler; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.services.compile.CompilerDiagnostic; import java.util.Collections; import java.util.List; /** * Result of an expression compilation attempt. * * @author Matt Coley */ public class ExpressionResult { private final String assembly; private final List diagnostics; private final ExpressionCompileException exception; /** * @param assembly * Output JASM from the input expression. */ public ExpressionResult(@Nonnull String assembly) { this.assembly = assembly; this.diagnostics = Collections.emptyList(); this.exception = null; } /** * @param diagnostics * Compiler warnings and errors from the input compilation attempt. */ public ExpressionResult(@Nonnull List diagnostics) { this.assembly = null; this.diagnostics = diagnostics; this.exception = null; } /** * @param exception * Exception thrown during the code-building or compilation process. */ public ExpressionResult(@Nonnull ExpressionCompileException exception) { this.assembly = null; this.diagnostics = Collections.emptyList(); this.exception = exception; } /** * @return The assembled expression. */ @Nullable public String getAssembly() { return assembly; } /** * @return Exception encountered when compiling the expression. */ @Nullable public ExpressionCompileException getException() { return exception; } /** * @return List of compiler errors encountered when compiling the expression. */ @Nonnull public List getDiagnostics() { return diagnostics; } /** * @return {@code true} when the expression was compiled and a {@link #getAssembly() method AST} was created. */ public boolean wasSuccess() { return assembly != null; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/assembler/JvmAssemblerPipeline.java ================================================ package software.coley.recaf.services.assembler; import jakarta.annotation.Nonnull; import me.darknet.assembler.ast.ASTElement; import me.darknet.assembler.compile.JavaClassRepresentation; import me.darknet.assembler.compile.JvmCompiler; import me.darknet.assembler.compile.JvmCompilerOptions; import me.darknet.assembler.compile.analysis.BasicFieldValueLookup; import me.darknet.assembler.compile.analysis.BasicMethodValueLookup; import me.darknet.assembler.compile.analysis.jvm.ValuedJvmAnalysisEngine; import me.darknet.assembler.compile.visitor.JavaCompileResult; import me.darknet.assembler.compiler.Compiler; import me.darknet.assembler.compiler.CompilerOptions; import me.darknet.assembler.compiler.InheritanceChecker; import me.darknet.assembler.error.Result; import me.darknet.assembler.parser.BytecodeFormat; import me.darknet.assembler.parser.processor.ASTProcessor; import me.darknet.assembler.printer.ClassPrinter; import me.darknet.assembler.printer.JvmClassPrinter; import org.slf4j.Logger; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.builder.JvmClassInfoBuilder; import software.coley.recaf.path.AnnotationPathNode; import software.coley.recaf.path.ClassMemberPathNode; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.path.PathNode; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.util.JavaVersion; import software.coley.recaf.workspace.model.Workspace; import java.io.ByteArrayInputStream; import java.util.List; /** * JVM assembler pipeline implementation. * * @author Justus Garbe */ public class JvmAssemblerPipeline extends AbstractAssemblerPipeline { public static final String SERVICE_ID = "jvm-assembler"; private static final Logger logger = Logging.get(JvmAssemblerPipeline.class); private final ASTProcessor processor = new ASTProcessor(BytecodeFormat.JVM); private final InheritanceGraph inheritanceGraph; private final Workspace workspace; public JvmAssemblerPipeline(@Nonnull Workspace workspace, @Nonnull InheritanceGraph inheritanceGraph, @Nonnull AssemblerPipelineGeneralConfig generalConfig, @Nonnull JvmAssemblerPipelineConfig jvmConfig) { super(generalConfig, jvmConfig); this.workspace = workspace; this.inheritanceGraph = inheritanceGraph; } @Nonnull @Override public Result> concreteParse(@Nonnull List elements) { return processor.processAST(elements); } @Nonnull @Override public Result assemble(@Nonnull List elements, @Nonnull PathNode path) { return compile(elements, path); } @Nonnull @Override public Result disassemble(@Nonnull ClassPathNode path) { return classPrinter(path).map(this::print); } @Nonnull @Override public Result disassemble(@Nonnull ClassMemberPathNode path) { return memberPrinter(path).map(this::print); } @Nonnull @Override public Result disassemble(@Nonnull AnnotationPathNode path) { return annotationPrinter(path).map(this::print); } @Nonnull @Override public JavaClassRepresentation getRepresentation(@Nonnull JvmClassInfo info) { return new JavaClassRepresentation(info.getBytecode()); } @Nonnull @Override protected CompilerOptions> getCompilerOptions() { JvmCompilerOptions options = new JvmCompilerOptions(); if (pipelineConfig.isValueAnalysisEnabled()) options.engineProvider(vars -> { ValuedJvmAnalysisEngine engine = new ValuedJvmAnalysisEngine(vars); if (pipelineConfig.isSimulatingCommonJvmCalls()) { engine.setFieldValueLookup(new WorkspaceFieldValueLookup(workspace, new BasicFieldValueLookup())); engine.setMethodValueLookup(new BasicMethodValueLookup()); } return engine; }); return options; } @Nonnull @Override protected Compiler getCompiler() { return new JvmCompiler(); } @Nonnull @Override protected InheritanceChecker getInheritanceChecker() { return new InheritanceChecker() { @Override public boolean isSubclassOf(String child, String parent) { return inheritanceGraph.isAssignableFrom(parent, child); } @Override public String getCommonSuperclass(String type1, String type2) { return inheritanceGraph.getCommon(type1, type2); } }; } @Override protected int getClassVersion(@Nonnull JvmClassInfo info) { return info.getVersion() - JavaVersion.VERSION_OFFSET; } @Nonnull @Override public JvmClassInfo getClassInfo(@Nonnull JavaClassRepresentation representation) { return new JvmClassInfoBuilder(representation.classFile()).build(); } @Nonnull @Override protected Result classPrinter(@Nonnull ClassPathNode path) { ClassInfo classInfo = path.getValue(); try { return Result.ok(new JvmClassPrinter(new ByteArrayInputStream(classInfo.asJvmClass().getBytecode()))); } catch (Throwable t) { logger.error("Uncaught error creating class printer for: {}", classInfo.getName(), t); return Result.exception(t); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/assembler/JvmAssemblerPipelineConfig.java ================================================ package software.coley.recaf.services.assembler; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.observables.ObservableBoolean; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.BasicConfigValue; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link JvmAssemblerPipeline}. * * @author Justus Garbe */ @ApplicationScoped public class JvmAssemblerPipelineConfig extends BasicConfigContainer implements ServiceConfig, AssemblerPipelineConfig { private final ObservableBoolean valueAnalysis = new ObservableBoolean(true); private final ObservableBoolean simulateJvmCalls = new ObservableBoolean(true); private final ObservableBoolean tryRangeComments = new ObservableBoolean(true); @Inject public JvmAssemblerPipelineConfig() { super(ConfigGroups.SERVICE_ASSEMBLER, JvmAssemblerPipeline.SERVICE_ID + CONFIG_SUFFIX); addValue(new BasicConfigValue<>("value-analysis", boolean.class, valueAnalysis)); addValue(new BasicConfigValue<>("simulate-jvm-calls", boolean.class, simulateJvmCalls)); addValue(new BasicConfigValue<>("try-range-comments", boolean.class, tryRangeComments)); } @Override public boolean isValueAnalysisEnabled() { return valueAnalysis.getValue(); } @Override public boolean isSimulatingCommonJvmCalls() { return simulateJvmCalls.getValue(); } /** * @return {@code true} to emit comments for where try-catch ranges start/end/catch. */ public boolean emitTryRangeComments() { return tryRangeComments.getValue(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/assembler/Snippet.java ================================================ package software.coley.recaf.services.assembler; import jakarta.annotation.Nonnull; import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator; import java.util.Comparator; /** * Outline of a named snippet of code for {@link SnippetManager}. * * @param name * Snippet name / title. * @param description * Description of the snippets content. * @param content * Snippet content. * * @author Matt Coley */ public record Snippet(@Nonnull String name, @Nonnull String description, @Nonnull String content) { /** * Shared comparator for snippets by name. */ public static final Comparator NAME_COMPARATOR = (a, b) -> CaseInsensitiveSimpleNaturalComparator.getInstance().compare(a.name(), b.name()); /** * @param newContent * New content. * * @return A copy of this snippet with different content specified. */ @Nonnull public Snippet withContent(@Nonnull String newContent) { return new Snippet(name(), description(), newContent); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/assembler/SnippetListener.java ================================================ package software.coley.recaf.services.assembler; import jakarta.annotation.Nonnull; import software.coley.recaf.behavior.PrioritySortable; /** * Listener for receiving updates when {@link Snippet} entries are added/updated/removed from {@link SnippetManager}. * * @author Matt Coley */ public interface SnippetListener extends PrioritySortable { /** * @param snippet * Newly added snippet. */ default void onSnippetAdded(@Nonnull Snippet snippet) {} /** * @param old * Old snippet instance. * @param current * New snippet instance. */ default void onSnippetModified(@Nonnull Snippet old, @Nonnull Snippet current) {} /** * @param snippet * Removed snippet. */ default void onSnippetRemoved(@Nonnull Snippet snippet) {} } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/assembler/SnippetManager.java ================================================ package software.coley.recaf.services.assembler; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.slf4j.Logger; import software.coley.collections.Unchecked; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.behavior.PrioritySortable; import software.coley.recaf.services.Service; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; /** * Simple code snippet manager to hold common assembler samples for common operations. * * @author Matt Coley */ @ApplicationScoped public class SnippetManager implements Service { private static final Logger logger = Logging.get(SnippetManager.class); public static final String SERVICE_ID = "snippets"; private final SnippetManagerConfig config; private final List listeners = new CopyOnWriteArrayList<>(); @Inject public SnippetManager(@Nonnull SnippetManagerConfig config) { this.config = config; } /** * @return List of recorded snippets. */ @Nonnull public List getSnippets() { return config.getSnippets().values().stream() .sorted(Snippet.NAME_COMPARATOR) .toList(); } /** * @param name * Name of snippet to look up. * * @return Snippet by the given name, if one exists. */ @Nullable public Snippet getByName(@Nonnull String name) { return config.getSnippets().get(name); } /** * Register or update a snippet. * * @param snippet * Snippet to register. * If an existing snippet with the same {@link Snippet#name()} exists, it will be replaced. */ public void putSnippet(@Nonnull Snippet snippet) { String name = snippet.name(); Snippet old = config.getSnippets().put(name, snippet); if (snippet.equals(old)) return; if (old == null) Unchecked.checkedForEach(listeners, listener -> listener.onSnippetAdded(snippet), (listener, t) -> logger.error("Exception thrown when registering snippet '{}'", name, t)); else Unchecked.checkedForEach(listeners, listener -> listener.onSnippetModified(old, snippet), (listener, t) -> logger.error("Exception thrown when updating snippet '{}'", name, t)); } /** * Remove a given snippet. * * @param snippet * Snippet to remove. */ public void removeSnippet(@Nonnull Snippet snippet) { removeSnippet(snippet.name()); } /** * Remove a given snippet by name. * * @param name * Snippet name / identifier. */ public void removeSnippet(@Nonnull String name) { Snippet removed = config.getSnippets().remove(name); if (removed != null) Unchecked.checkedForEach(listeners, listener -> listener.onSnippetRemoved(removed), (listener, t) -> logger.error("Exception thrown when removing snippet '{}'", name, t)); } /** * @param listener * Listener to add. */ public void addSnippetListener(@Nonnull SnippetListener listener) { PrioritySortable.add(listeners, listener); } /** * @param listener * Listener to remove. */ public void removeSnippetListener(@Nonnull SnippetListener listener) { listeners.remove(listener); } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public SnippetManagerConfig getServiceConfig() { return config; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/assembler/SnippetManagerConfig.java ================================================ package software.coley.recaf.services.assembler; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.observables.ObservableMap; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.BasicMapConfigValue; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.config.RestoreAwareConfigContainer; import software.coley.recaf.services.ServiceConfig; import software.coley.recaf.services.json.GsonProvider; import java.util.HashMap; import java.util.Map; /** * Config for {@link SnippetManager}. * * @author Matt Coley */ @ApplicationScoped public class SnippetManagerConfig extends BasicConfigContainer implements ServiceConfig, RestoreAwareConfigContainer { private final SnippetMap snippets = new SnippetMap(); @Inject public SnippetManagerConfig(@Nonnull GsonProvider gsonProvider) { super(ConfigGroups.SERVICE_UI, SnippetManager.SERVICE_ID + CONFIG_SUFFIX); addValue(new BasicMapConfigValue<>("snippet-map", SnippetMap.class, String.class, Snippet.class, snippets, true)); } @Override public void onNoRestore() { snippets.put("println", new Snippet("println", "A simple single-string println() call", """ // System.out.println("Hello"); getstatic java/lang/System.out Ljava/io/PrintStream; ldc "Hello" invokevirtual java/io/PrintStream.println (Ljava/lang/String;)V """)); snippets.put("println-fmt", new Snippet("println formatted", "A formatted string println() call", """ // String name = "bob"; // System.out.printf("hello %s\\n", name); start: ldc "bob" astore name printf: getstatic java/lang/System.out Ljava/io/PrintStream; ldc "hello %s\\u000A" iconst_1 anewarray Ljava/lang/Object; dup iconst_0 aload name aastore invokevirtual java/io/PrintStream.printf (Ljava/lang/String;[Ljava/lang/Object;)Ljava/io/PrintStream; pop end: """)); snippets.put("fori", new Snippet("for-i", "for-loop between [0, 9]", """ // for (int i = 0; i < 10; i++) someMethod(); init: // int i = 0 iconst_0 istore i check: // if (i >= 10) break iload i bipush 10 if_icmpge exit contents: // Inside the for-loop invokestatic com/example/MyClass.someMethod ()V inc: // i++ and continue; portion of for-loop iinc i 1 goto check exit: // End of for-loop """)); snippets.put("while", new Snippet("while", "A simple while loop", """ // int i = 100; // while (i >= 0) { // someMethod(); // i--; // } init: // int i = 100; bipush 100 istore i check: // if (i < 0) break; iload i iflt exit contents: // Inside the while loop invokestatic com/example/MyClass.someMethod ()V iinc i -1 goto check exit: // End of while-loop """)); snippets.put("if-else", new Snippet("if ... else ...", "A simple if else statement", """ // if (b) // whenTrue(); // else // whenFalse(); start: iload someBoolean ifeq isFalse invokestatic com/example/MyClass.whenTrue ()V goto end isFalse: invokestatic com/example/MyClass.whenFalse ()V end: """)); } /** * @return Map of recorded snippets. */ @Nonnull public SnippetMap getSnippets() { return snippets; } /** * Map type to hold snippets. */ public static class SnippetMap extends ObservableMap> { public SnippetMap() { super(HashMap::new); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/assembler/WorkspaceFieldValueLookup.java ================================================ package software.coley.recaf.services.assembler; import dev.xdark.blw.code.instruction.FieldInstruction; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import me.darknet.assembler.compile.analysis.Value; import me.darknet.assembler.compile.analysis.Values; import me.darknet.assembler.compile.analysis.jvm.FieldValueLookup; import org.objectweb.asm.Opcodes; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.workspace.model.Workspace; /** * Field value lookup for items in a {@link Workspace}. *

* Its very basic, only looking at static fields default values. * * @author Matt Coley */ public class WorkspaceFieldValueLookup implements FieldValueLookup { private final Workspace workspace; private final FieldValueLookup parent; /** * @param workspace * Workspace to pull values from. * @param parent * Parent lookup if no such value could be found in the workspace. */ public WorkspaceFieldValueLookup(@Nonnull Workspace workspace, @Nullable FieldValueLookup parent) { this.workspace = workspace; this.parent = parent; } @Override @Nullable public Value accept(@Nonnull FieldInstruction fieldRef, @Nullable Value.ObjectValue context) { if (fieldRef.opcode() == Opcodes.GETSTATIC) { ClassPathNode owner = workspace.findClass(fieldRef.owner().internalName()); if (owner != null) { FieldMember field = owner.getValue().getDeclaredField(fieldRef.name(), fieldRef.type().descriptor()); if (field != null) { Object value = field.getDefaultValue(); if (value instanceof Integer v) return Values.valueOf(v); if (value instanceof Long v) return Values.valueOf(v); if (value instanceof Float v) return Values.valueOf(v); if (value instanceof Double v) return Values.valueOf(v); if (value instanceof String v) return Values.valueOfString(v); } } } if (parent == null) return null; return parent.accept(fieldRef, context); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/attach/AttachManager.java ================================================ package software.coley.recaf.services.attach; import com.sun.tools.attach.VirtualMachine; import com.sun.tools.attach.VirtualMachineDescriptor; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.collections.observable.ObservableList; import software.coley.recaf.services.Service; import software.coley.recaf.workspace.model.resource.WorkspaceRemoteVmResource; import java.io.IOException; import java.util.Properties; /** * Outline for attach service. * * @author Matt Coley */ public interface AttachManager extends Service { String SERVICE_ID = "attach"; /** * @return {@code true} when attach is supported. * Typically only {@code false} when the agent fails to extract onto the local file system. */ boolean canAttach(); /** * Refresh available remote JVMs. */ void scan(); /** * Create a {@link WorkspaceRemoteVmResource} for the given VM. * Users must call {@link WorkspaceRemoteVmResource#connect()} to 'enable' the resource. * * @param item * VM descriptor for VM to connect to. * * @return Agent client resource, not yet connected. * * @throws IOException * When the remote resource couldn't be created. * Causing exceptions depend on implementation. */ @Nonnull WorkspaceRemoteVmResource createRemoteResource(VirtualMachineDescriptor item) throws IOException; /** * @param descriptor * Lookup descriptor. * * @return Remote VM, if known. Otherwise {@code null}. */ @Nullable VirtualMachine getVirtualMachine(@Nonnull VirtualMachineDescriptor descriptor); /** * @param descriptor * Lookup descriptor. * * @return Exception when attempting to connect to remote VM, if there was one. Otherwise {@code null}. */ @Nullable Exception getVirtualMachineConnectionFailure(@Nonnull VirtualMachineDescriptor descriptor); /** * @param descriptor * Lookup descriptor. * * @return Remote VM PID, or {@code -1} if no PID is known for the remote VM. */ int getVirtualMachinePid(@Nonnull VirtualMachineDescriptor descriptor); /** * @param descriptor * Lookup descriptor. * * @return Remote VM {@link System#getProperties()} if known, otherwise {@code null}. */ @Nullable Properties getVirtualMachineProperties(@Nonnull VirtualMachineDescriptor descriptor); /** * @param descriptor * Lookup descriptor. * * @return Remote main class of VM. */ @Nullable String getVirtualMachineMainClass(@Nonnull VirtualMachineDescriptor descriptor); /** * @param descriptor * Lookup descriptor. * * @return JMX bean server connection to remote VM. */ @Nullable JmxBeanServerConnection getJmxServerConnection(@Nonnull VirtualMachineDescriptor descriptor); /** * @return Observable list of virtual machine descriptors. */ @Nonnull ObservableList getVirtualMachineDescriptors(); /** * @param listener * Listener to add. */ void addPostScanListener(@Nonnull PostScanListener listener); /** * @param listener * Listener to remove. */ void removePostScanListener(@Nonnull PostScanListener listener); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/attach/AttachManagerConfig.java ================================================ package software.coley.recaf.services.attach; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.observables.ObservableBoolean; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.BasicConfigValue; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; import software.coley.recaf.services.file.RecafDirectoriesConfig; import javax.management.MBeanServerConnection; import java.nio.file.Path; /** * Config for {@link AttachManager}. * * @author Matt Coley */ @ApplicationScoped public class AttachManagerConfig extends BasicConfigContainer implements ServiceConfig { private final RecafDirectoriesConfig directories; private final ObservableBoolean passiveScanning = new ObservableBoolean(false); private final ObservableBoolean attachJmxAgent = new ObservableBoolean(true); @Inject public AttachManagerConfig(@Nonnull RecafDirectoriesConfig directories) { super(ConfigGroups.SERVICE_DEBUG, AttachManager.SERVICE_ID + CONFIG_SUFFIX); this.directories = directories; // Add values // - The 'passiveScanning' field is *intentionally* not registered as a value. addValue(new BasicConfigValue<>("attach-jmx-bean-agent", boolean.class, attachJmxAgent)); } /** * @return Mirror of {@link RecafDirectoriesConfig#getAgentDirectory()}. */ public Path getAgentDirectory() { return directories.getAgentDirectory(); } /** * @return {@code true} to enable passive scanning in the {@link AttachManager}. */ public ObservableBoolean getPassiveScanning() { return passiveScanning; } /** * @return {@code true} to enable attaching the JMX agent to discovered servers, * allowing usage of {@link MBeanServerConnection}. */ public ObservableBoolean getAttachJmxAgent() { return attachJmxAgent; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/attach/BasicAttachManager.java ================================================ package software.coley.recaf.services.attach; import com.sun.tools.attach.*; import com.sun.tools.attach.spi.AttachProvider; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.slf4j.Logger; import software.coley.collections.Unchecked; import software.coley.collections.observable.ObservableList; import software.coley.instrument.BuildConfig; import software.coley.instrument.Client; import software.coley.instrument.Extractor; import software.coley.instrument.io.ByteBufferAllocator; import software.coley.instrument.message.MessageFactory; import software.coley.instrument.sock.SocketAvailability; import software.coley.instrument.util.Discovery; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.behavior.PrioritySortable; import software.coley.recaf.util.DevDetection; import software.coley.recaf.util.StringUtil; import software.coley.recaf.util.threading.ThreadUtil; import software.coley.recaf.workspace.model.resource.AgentServerRemoteVmResource; import software.coley.recaf.workspace.model.resource.WorkspaceRemoteVmResource; import javax.management.MBeanServerConnection; import javax.management.remote.JMXConnector; import javax.management.remote.JMXConnectorFactory; import javax.management.remote.JMXServiceURL; import java.io.IOException; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; import java.util.concurrent.*; import java.util.jar.JarFile; /** * Manager for handling instrumentation of remote JVMs. * * @author Matt Coley */ @ApplicationScoped public class BasicAttachManager implements AttachManager { private static final Logger logger = Logging.get(BasicAttachManager.class); private static final long currentPid = ProcessHandle.current().pid(); private static final String JMX_AGENT_ADDRESS = "com.sun.management.jmxremote.localConnectorAddress"; private static ExtractState extractState = ExtractState.DEFAULT; private final DescriptorComparator descriptorComparator = new DescriptorComparator(); private final Map virtualMachineMap = new ConcurrentHashMap<>(); private final Map virtualMachineFailureMap = new ConcurrentHashMap<>(); private final Map virtualMachinePidMap = new ConcurrentHashMap<>(); private final Map virtualMachinePropertiesMap = new ConcurrentHashMap<>(); private final Map virtualMachineMainClassMap = new ConcurrentHashMap<>(); private final Map virtualMachineJmxConnMap = new ConcurrentHashMap<>(); private final ObservableList virtualMachineDescriptors = new ObservableList<>(); private final List postScanListeners = new CopyOnWriteArrayList<>(); private final AttachManagerConfig config; private ScheduledFuture future; @Inject public BasicAttachManager(AttachManagerConfig config) { this.config = config; extractAgent(); } /** * Extracts the agent jar from Recaf to its own file. */ private void extractAgent() { if (extractState == ExtractState.DEFAULT) { Path agentPath = getAgentJarPath(); if (!Files.isRegularFile(agentPath)) { // Not extracted already try { logger.debug("Extracting agent jar to Recaf directory: {}", agentPath.getFileName()); Files.createDirectories(config.getAgentDirectory()); Extractor.extractToPath(agentPath); future = ThreadUtil.scheduleAtFixedRate(this::passiveScanUpdate, 0, 1, TimeUnit.SECONDS); extractState = ExtractState.SUCCESS; } catch (IOException ex) { logger.error("Failed to extract agent jar to Recaf directory", ex); extractState = ExtractState.FAILURE; } } else { // Already extracted before extractState = ExtractState.SUCCESS; future = ThreadUtil.scheduleAtFixedRate(this::passiveScanUpdate, 0, 1, TimeUnit.SECONDS); } } } /** * Cancel passive scan loop when shutting down. */ @PreDestroy private void onShutdown() { future.cancel(true); } /** * Check for new virtual machines in the background. */ private void passiveScanUpdate() { try { if (config.getPassiveScanning().getValue()) scan(); } catch (Throwable t) { logger.error("Unhandled exception in JVM scan", t); } } /** * @param descriptor * VM descriptor. * * @return Main class of VM, if possible to resolve. */ @Nonnull private String mapToMainClass(@Nonnull VirtualMachineDescriptor descriptor) { // Get source string to find main class name from String source = descriptor.displayName(); if (source == null || source.isBlank() || source.toLowerCase().contains(".jar")) { Properties properties = virtualMachinePropertiesMap.get(descriptor); if (properties != null) { // Check if we can get the main class from the command. // It may start with it, such as "com.example.Main args..." // it may be a file path, such as from "-jar " String command = properties.getProperty("sun.java.command", ""); // Check if the command is a path. String commandLower = command.toLowerCase(); int jarIndex = commandLower.indexOf(".jar"); if (jarIndex > 0) { // Depending on the invocation, it may not be the full path. // We may need to pre-pend the current directory String commandJarName = command.substring(0, jarIndex + 4); try { Path jarPath = Paths.get(commandJarName); if (!Files.isRegularFile(jarPath)) { // Prepend remote vm's user directory String commandUserDir = properties.getProperty("user.dir"); if (commandUserDir.endsWith("/")) commandUserDir = commandUserDir.substring(0, commandUserDir.length() - 1); if (commandJarName.startsWith("/")) commandJarName = commandJarName.substring(1); jarPath = Paths.get(commandUserDir + "/" + commandJarName); } // Read main class attribute from jar manifest if (Files.isRegularFile(jarPath)) { try (JarFile jar = new JarFile(jarPath.toFile())) { source = jar.getManifest().getMainAttributes().getValue("Main-Class"); } catch (IOException ignored) { // Can't read from jar, oh well } } } catch (InvalidPathException ignored) { // Expected for cases like 'com.example.Main foo.jar' // In this case we know the substring up to the '.jar' isn't a path in totality. // Only a section of it is, so likely the '.jar' match is part of an argument. } } } } // Still null/missing? Give up if (source == null || source.isEmpty()) return ""; // Some 'display name' values are ' ' so strip out the args String trim = source.trim(); int end = trim.indexOf(' '); if (end == -1) end = trim.length(); return trim.substring(0, end); // Alternative idea for later: Use 'sun/launcher/LauncherHelper' as mentioned by xxDark // - reliable source for main-class } /** * @param descriptor * VM descriptor. * * @return PID of VM process. */ private int mapToPid(@Nonnull VirtualMachineDescriptor descriptor) { String id = descriptor.id(); if (id.matches("\\d+")) { return Integer.parseInt(descriptor.id()); } else { return -1; } } /** * @return {@code true} when the agent jar is extracted and ready to be used. * {@code false} means instrumentation cannot be done since the agent is not available. */ public static boolean isAgentReady() { return extractState == ExtractState.SUCCESS; } /** * @return Path to agent jar file. */ @Nonnull public Path getAgentJarPath() { String jarName = "agent-" + BuildConfig.VERSION + ".jar"; return config.getAgentDirectory().resolve(jarName); } @Override public boolean canAttach() { return isAgentReady(); } @Override public void scan() { int numDescriptors = virtualMachineDescriptors.size(); List remoteVmList = VirtualMachine.list(); Set toRemove = new HashSet<>(virtualMachineDescriptors); Set toAdd = new HashSet<>(); List> attachFutures = new ArrayList<>(); for (VirtualMachineDescriptor descriptor : remoteVmList) { // Still active in VM list, keep it. toRemove.remove(descriptor); // Add if not in the list. if (!virtualMachineDescriptors.contains(descriptor)) { String label = descriptor.id() + " - " + StringUtil.withEmptyFallback(descriptor.displayName(), "?"); int pid = mapToPid(descriptor); if (pid == currentPid) // skip self continue; // Using futures for attach in case one of the VM's decides to hang on response. // Using 'orTimeout' we can prevent such hangs from affecting us. attachFutures.add(ThreadUtil.supply(() -> { try { AttachProvider provider = descriptor.provider(); return provider.attachVirtualMachine(descriptor); } catch (IOException ex) { virtualMachineFailureMap.put(descriptor, ex); logger.debug("Remote JVM descriptor found (attach-success, read-failure): " + label); } catch (AttachNotSupportedException ex) { virtualMachineFailureMap.put(descriptor, ex); logger.debug("Remote JVM descriptor found (attach-failure): " + label); } catch (Throwable t) { logger.error("Unhandled exception populating remote VM info", t); } return null; }, null).orTimeout(500, TimeUnit.MILLISECONDS).thenAccept(machine -> { // Get information from machine if it is available. if (machine != null) { virtualMachineMap.put(descriptor, machine); logger.debug("Remote JVM descriptor found (attach-success): " + label); // Extract additional information try { Properties systemProperties = machine.getSystemProperties(); virtualMachinePropertiesMap.put(descriptor, systemProperties); virtualMachinePidMap.put(descriptor, pid); virtualMachineMainClassMap.put(descriptor, mapToMainClass(descriptor)); // Enable optional JMX agent if (config.getAttachJmxAgent().getValue()) { try { Properties agentProperties = machine.getAgentProperties(); String serviceUrl = agentProperties.getProperty(JMX_AGENT_ADDRESS); if (serviceUrl == null) { serviceUrl = machine.startLocalManagementAgent(); } if (serviceUrl != null) { JMXServiceURL url = new JMXServiceURL(serviceUrl); @SuppressWarnings("resource") // Do NOT wrap this in a try-with-resource. It will close the connection. JMXConnector connector = JMXConnectorFactory.connect(url); MBeanServerConnection connection = connector.getMBeanServerConnection(); virtualMachineJmxConnMap.put(descriptor, new JmxBeanServerConnection(connection)); } else { logger.warn("Could fetch JMX agent address, skipping connection for: {}", label); } } catch (Exception ex) { logger.error("Failed to attach JMX agent to remote JVM: {}", label, ex); } } } catch (IOException ex) { logger.error("Could not read system properties from remote JVM: " + label, ex); } } // Add to list for listener call later. toAdd.add(descriptor); // Insert descriptor in sorted order. int lastComparison = 1; synchronized (virtualMachineDescriptors) { for (int i = 0; i < numDescriptors; i++) { VirtualMachineDescriptor other = virtualMachineDescriptors.get(i); int comparison = descriptorComparator.compare(descriptor, other); if (comparison < lastComparison) { virtualMachineDescriptors.add(i, descriptor); return; } } // Greater than all entries, append to end virtualMachineDescriptors.add(descriptor); } })); } } // When all attach attachFutures complete, update the observable list to update the UI ThreadUtil.allOf(attachFutures.toArray(new CompletableFuture[0])).thenRun(() -> { // Remove entries not visited in this pass virtualMachineDescriptors.removeAll(toRemove); for (VirtualMachineDescriptor descriptor : toRemove) { String label = descriptor.id() + " - " + StringUtil.withEmptyFallback(descriptor.displayName(), "?"); logger.debug("Remote JVM descriptor removed: " + label); } // Call listeners Unchecked.checkedForEach(postScanListeners, listener -> listener.onScanCompleted(toAdd, toRemove), (listener, t) -> logger.error("Exception thrown after scan completion", t)); }); } @Nonnull @Override public WorkspaceRemoteVmResource createRemoteResource(VirtualMachineDescriptor item) throws IOException { VirtualMachine virtualMachine = virtualMachineMap.get(item); try { // Will initialize agent server with default arguments Properties properties = virtualMachinePropertiesMap.get(item); int port = Discovery.extractPort(properties); if (port <= 0) { // Port not found, server is not running on remote VM. // Load the agent to start it. try { port = SocketAvailability.findAvailable(); String agentAbsolutePath = StringUtil.pathToAbsoluteString(getAgentJarPath()); String agentArgs = "port=" + port+",notrampolines"; if (DevDetection.isDevEnv()) agentArgs += ",debug"; else agentArgs += ",namelessThreads"; virtualMachine.loadAgent(agentAbsolutePath, agentArgs); // The agent server will update some properties to indicate its active. // We need to update our map so that we can see this indicator so that we can extract // the port that the server is running on if we want to reconnect. Properties systemProperties = virtualMachine.getSystemProperties(); virtualMachinePropertiesMap.put(item, systemProperties); } catch (AgentLoadException ex) { // The agent jar file is written in Java 8. But Recaf uses Java 11+. // This is a problem on OUR side because Java 11+ handles agent interactions differently. // - https://stackoverflow.com/a/54454418/ // Basically in Java 10 they added a prefix string requirement. // But the Java 8 VM doesn't have that so our VM will mark it as invalid. if (!ex.getMessage().equals("0")) // If the result we get back is '0' then that means it was actually a success throw ex; } } // Connect with client Client client = new Client("localhost", port, ByteBufferAllocator.HEAP, MessageFactory.create()); return new AgentServerRemoteVmResource(virtualMachine, client); } catch (AgentLoadException ex) { logger.error("Agent on remote VM '{}' could not be loaded", item, ex); throw new IOException("Failed remote load", ex); } catch (AgentInitializationException ex) { logger.error("Agent on remote VM '{}' crashed on initialization", item, ex); throw new IOException("Failed remote initialization", ex); } catch (IOException ex) { logger.error("IO error when loading agent to remote VM '{}'", item, ex); throw ex; } } @Override public VirtualMachine getVirtualMachine(@Nonnull VirtualMachineDescriptor descriptor) { return virtualMachineMap.get(descriptor); } @Override public Exception getVirtualMachineConnectionFailure(@Nonnull VirtualMachineDescriptor descriptor) { return virtualMachineFailureMap.get(descriptor); } @Override public int getVirtualMachinePid(@Nonnull VirtualMachineDescriptor descriptor) { return virtualMachinePidMap.getOrDefault(descriptor, -1); } @Nullable @Override public Properties getVirtualMachineProperties(@Nonnull VirtualMachineDescriptor descriptor) { return virtualMachinePropertiesMap.get(descriptor); } @Nullable @Override public String getVirtualMachineMainClass(@Nonnull VirtualMachineDescriptor descriptor) { return virtualMachineMainClassMap.get(descriptor); } @Nullable @Override public JmxBeanServerConnection getJmxServerConnection(@Nonnull VirtualMachineDescriptor descriptor) { return virtualMachineJmxConnMap.get(descriptor); } @Nonnull @Override public ObservableList getVirtualMachineDescriptors() { return virtualMachineDescriptors; } @Override public void addPostScanListener(@Nonnull PostScanListener listener) { PrioritySortable.add(postScanListeners, listener); } @Override public void removePostScanListener(@Nonnull PostScanListener listener) { postScanListeners.remove(listener); } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public AttachManagerConfig getServiceConfig() { return config; } /** * Comparator for {@link VirtualMachineDescriptor} using {@link #mapToMainClass(VirtualMachineDescriptor)}. */ class DescriptorComparator implements Comparator { @Override public int compare(VirtualMachineDescriptor o1, VirtualMachineDescriptor o2) { String k1 = virtualMachineMainClassMap.getOrDefault(o1, o1.displayName()); String k2 = virtualMachineMainClassMap.getOrDefault(o2, o2.displayName()); return k1.compareTo(k2); } } enum ExtractState { DEFAULT, SUCCESS, FAILURE } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/attach/JmxBeanServerConnection.java ================================================ package software.coley.recaf.services.attach; import jakarta.annotation.Nonnull; import javax.management.MBeanServerConnection; import javax.management.MalformedObjectNameException; import javax.management.ObjectName; import java.lang.management.*; /** * Helper to wrap {@link MBeanServerConnection}. * * @author Matt Coley */ public class JmxBeanServerConnection { public static final ObjectName CLASS_LOADING = named(ManagementFactory.CLASS_LOADING_MXBEAN_NAME); public static final ObjectName COMPILATION = named(ManagementFactory.COMPILATION_MXBEAN_NAME); public static final ObjectName OPERATING_SYSTEM = named(ManagementFactory.OPERATING_SYSTEM_MXBEAN_NAME); public static final ObjectName RUNTIME = named(ManagementFactory.RUNTIME_MXBEAN_NAME); public static final ObjectName THREAD = named(ManagementFactory.THREAD_MXBEAN_NAME); // The following beans require knowing the bean's domain in advance to supply it as an additional parameter: // - GARBAGE_COLLECTOR_MXBEAN_DOMAIN_TYPE // - MEMORY_MANAGER_MXBEAN_DOMAIN_TYPE // - MEMORY_POOL_MXBEAN_DOMAIN_TYPE private final MBeanServerConnection connection; /** * @param connection * Underlying connection. */ public JmxBeanServerConnection(@Nonnull MBeanServerConnection connection) { this.connection = connection; } /** * @return Attributes and operations of {@link ClassLoadingMXBean}. * * @throws Exception * When the bean could not be fetched. */ @Nonnull public NamedMBeanInfo getClassloadingBeanInfo() throws Exception { return new NamedMBeanInfo(CLASS_LOADING, connection.getMBeanInfo(CLASS_LOADING)); } /** * @return Attributes and operations of {@link CompilationMXBean}. * * @throws Exception * When the bean could not be fetched. */ @Nonnull public NamedMBeanInfo getCompilationBeanInfo() throws Exception { return new NamedMBeanInfo(COMPILATION, connection.getMBeanInfo(COMPILATION)); } /** * @return Attributes and operations of {@link OperatingSystemMXBean}. * * @throws Exception * When the bean could not be fetched. */ @Nonnull public NamedMBeanInfo getOperatingSystemBeanInfo() throws Exception { return new NamedMBeanInfo(OPERATING_SYSTEM, connection.getMBeanInfo(OPERATING_SYSTEM)); } /** * @return Attributes and operations of {@link RuntimeMXBean}. * * @throws Exception * When the bean could not be fetched. */ @Nonnull public NamedMBeanInfo getRuntimeBeanInfo() throws Exception { return new NamedMBeanInfo(RUNTIME, connection.getMBeanInfo(RUNTIME)); } /** * @return Attributes and operations of {@link ThreadMXBean}. * * @throws Exception * When the bean could not be fetched. */ @Nonnull public NamedMBeanInfo getThreadBeanInfo() throws Exception { return new NamedMBeanInfo(THREAD, connection.getMBeanInfo(THREAD)); } /** * @return Underlying connection. */ @Nonnull public MBeanServerConnection getConnection() { return connection; } @Nonnull private static ObjectName named(@Nonnull String name) { try { return new ObjectName(name); } catch (MalformedObjectNameException ex) { throw new IllegalStateException(ex); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/attach/NamedMBeanInfo.java ================================================ package software.coley.recaf.services.attach; import jakarta.annotation.Nonnull; import javax.management.*; /** * Extension of {@link MBeanInfo} providing access to the associated {@link ObjectName}. * * @author Matt Coley */ public class NamedMBeanInfo extends MBeanInfo { private final ObjectName objectName; /** * @param objectName * Associated name to the bean info. * @param info * Wrapped bean info. */ public NamedMBeanInfo(@Nonnull ObjectName objectName, @Nonnull MBeanInfo info) { this(objectName, info.getClassName(), info.getDescription(), info.getAttributes(), info.getConstructors(), info.getOperations(), info.getNotifications(), info.getDescriptor()); } private NamedMBeanInfo(@Nonnull ObjectName objectName, String className, String description, MBeanAttributeInfo[] attributes, MBeanConstructorInfo[] constructors, MBeanOperationInfo[] operations, MBeanNotificationInfo[] notifications, Descriptor descriptor) throws IllegalArgumentException { super(className, description, attributes, constructors, operations, notifications, descriptor); this.objectName = objectName; } /** * @return Key to use in {@link MBeanServerConnection} operations. */ @Nonnull public ObjectName getObjectName() { return objectName; } /** * @param connection * JMX connection. * @param attribute * Attribute to get value of. * * @return Value of attribute. * * @throws Exception * See: {@link MBeanServerConnection#getAttribute(ObjectName, String)}. */ public Object getAttributeValue(@Nonnull JmxBeanServerConnection connection, @Nonnull MBeanAttributeInfo attribute) throws Exception { return getAttributeValue(connection, attribute.getName()); } /** * @param connection * JMX connection. * @param attributeName * Name of attribute to get value of. * * @return Value of attribute. * * @throws Exception * See: {@link MBeanServerConnection#getAttribute(ObjectName, String)}. */ public Object getAttributeValue(@Nonnull JmxBeanServerConnection connection, @Nonnull String attributeName) throws Exception { return connection.getConnection().getAttribute(getObjectName(), attributeName); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/attach/PostScanListener.java ================================================ package software.coley.recaf.services.attach; import com.sun.tools.attach.VirtualMachineDescriptor; import jakarta.annotation.Nonnull; import software.coley.recaf.behavior.PrioritySortable; import java.util.Set; /** * Listener called after {@link AttachManager#scan()} completion. * * @author Matt Coley */ public interface PostScanListener extends PrioritySortable { /** * Called when scan is completed. * * @param added * Newly found VMs. * @param removed * Old VMs that are no longer available. */ void onScanCompleted(@Nonnull Set added, @Nonnull Set removed); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/callgraph/CachedLinkResolver.java ================================================ package software.coley.recaf.services.callgraph; import dev.xdark.jlinker.ClassInfo; import dev.xdark.jlinker.LinkResolver; import dev.xdark.jlinker.Resolution; import dev.xdark.jlinker.Result; import jakarta.annotation.Nonnull; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.util.MemoizedFunctions; import java.util.function.BiFunction; import java.util.function.Function; /** * Memoized implementation of {@link LinkResolver}. * * @author Amejonah */ public class CachedLinkResolver implements LinkResolver { private final LinkResolver backedResolver = LinkResolver.jvm(); private final Function, BiFunction>>> virtualMethodResolver = MemoizedFunctions.memoize( c -> MemoizedFunctions.memoize((name, descriptor) -> backedResolver.resolveVirtualMethod(c, name, descriptor)) ); private final Function, BiFunction>>> staticMethodResolver = MemoizedFunctions.memoize( c -> MemoizedFunctions.memoize((name, descriptor) -> backedResolver.resolveStaticMethod(c, name, descriptor)) ); private final Function, BiFunction>>> interfaceMethodResolver = MemoizedFunctions.memoize( c -> MemoizedFunctions.memoize((name, descriptor) -> backedResolver.resolveInterfaceMethod(c, name, descriptor)) ); private final Function, BiFunction>>> virtualFieldResolver = MemoizedFunctions.memoize( c -> MemoizedFunctions.memoize((name, descriptor) -> backedResolver.resolveVirtualField(c, name, descriptor)) ); private final Function, BiFunction>>> staticFieldResolver = MemoizedFunctions.memoize( c -> MemoizedFunctions.memoize((name, descriptor) -> backedResolver.resolveStaticField(c, name, descriptor)) ); private final Function, BiFunction>>> specialMethodResolver = MemoizedFunctions.memoize( c -> MemoizedFunctions.memoize((name, descriptor) -> backedResolver.resolveSpecialMethod(c, name, descriptor)) ); @Override public Result> resolveStaticMethod(@Nonnull ClassInfo owner, @Nonnull String name, @Nonnull String descriptor, boolean itf) { return staticMethodResolver.apply(owner).apply(name, descriptor); } @Override public Result> resolveSpecialMethod(@Nonnull ClassInfo owner, @Nonnull String name, @Nonnull String descriptor, boolean itf) { return specialMethodResolver.apply(owner).apply(name, descriptor); } @Override public Result> resolveVirtualMethod(@Nonnull ClassInfo owner, @Nonnull String name, @Nonnull String descriptor) { return virtualMethodResolver.apply(owner).apply(name, descriptor); } @Override public Result> resolveInterfaceMethod(@Nonnull ClassInfo owner, @Nonnull String name, @Nonnull String descriptor) { return interfaceMethodResolver.apply(owner).apply(name, descriptor); } @Override public Result> resolveStaticField(@Nonnull ClassInfo owner, @Nonnull String name, @Nonnull String descriptor) { return staticFieldResolver.apply(owner).apply(name, descriptor); } @Override public Result> resolveVirtualField(@Nonnull ClassInfo owner, @Nonnull String name, @Nonnull String descriptor) { return virtualFieldResolver.apply(owner).apply(name, descriptor); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/callgraph/CallGraph.java ================================================ package software.coley.recaf.services.callgraph; import com.google.common.annotations.VisibleForTesting; import dev.xdark.jlinker.MemberInfo; import dev.xdark.jlinker.Resolution; import dev.xdark.jlinker.ResolutionError; import dev.xdark.jlinker.Result; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.Handle; import org.objectweb.asm.MethodVisitor; import org.objectweb.asm.Opcodes; import software.coley.observables.ObservableBoolean; import software.coley.recaf.RecafConstants; import software.coley.recaf.analytics.logging.DebuggingLogger; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.util.AsmInsnUtil; import software.coley.recaf.util.MultiMap; import software.coley.recaf.util.threading.ThreadPoolFactory; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.WorkspaceModificationListener; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.ResourceJvmClassListener; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.IdentityHashMap; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; /** * Represents method calls as a navigable graph. * * @author Amejonah * @author Matt Coley * @see MethodVertex */ public class CallGraph implements WorkspaceModificationListener, ResourceJvmClassListener { private static final DebuggingLogger logger = Logging.get(CallGraph.class); private final ExecutorService threadPool = ThreadPoolFactory.newFixedThreadPool("call-graph", 1, true); private final CachedLinkResolver resolver = new CachedLinkResolver(); private final Map classToLinkerType = Collections.synchronizedMap(new IdentityHashMap<>()); private final Map classToMethodsContainer = Collections.synchronizedMap(new IdentityHashMap<>()); private final MultiMap> unresolvedDeclarations = MultiMap.from( new ConcurrentHashMap<>(), ConcurrentHashMap::newKeySet); private final MultiMap> unresolvedReferences = MultiMap.from( new ConcurrentHashMap<>(), ConcurrentHashMap::newKeySet); private final ObservableBoolean isReady = new ObservableBoolean(false); private final Workspace workspace; private final ClassLookup lookup; private boolean initialized; /** * @param workspace * Workspace to pull data from. */ public CallGraph(@Nonnull Workspace workspace) { this.workspace = workspace; lookup = new ClassLookup(workspace); } /** * @return {@code true} when {@link #initialize()} has been called. */ public boolean isInitialized() { return initialized; } /** * @return Observable boolean tracking the state of the call-graph's parsing of the current workspace. */ @Nonnull public ObservableBoolean isReady() { return isReady; } /** * @param classInfo * Class to wrap. * * @return Wrapper for easy {@link MethodVertex} management for the class. */ @Nonnull public ClassMethodsContainer getClassMethodsContainer(@Nonnull JvmClassInfo classInfo) { return classToMethodsContainer.computeIfAbsent(classInfo, c -> new ClassMethodsContainer(classInfo)); } /** * @param method * Method to get vertex of. * * @return Vertex of method. Can be {@code null} if the method member does not * define its {@link MethodMember#getDeclaringClass() declaring class}. */ @Nullable public MethodVertex getVertex(@Nonnull MethodMember method) { ClassInfo declaringClass = method.getDeclaringClass(); if (declaringClass == null) return null; return getClassMethodsContainer(declaringClass.asJvmClass()).getVertex(method); } /** * @param classInfo * Class to wrap. * * @return JLinker wrapper for class. */ @Nonnull private LinkedClass linked(@Nonnull JvmClassInfo classInfo) { return classToLinkerType.computeIfAbsent(classInfo, c -> new LinkedClass(lookup, c)); } /** * Initialize the graph. */ public void initialize() { // Only allow calls to initialize the graph once if (initialized) return; initialized = true; // Register modification listeners so that we can update the graph when class state changes. workspace.addWorkspaceModificationListener(this); workspace.getPrimaryResource().addResourceJvmClassListener(this); // Initialize asynchronously, and mark 'isReady' if completed successfully CompletableFuture.runAsync(() -> { for (WorkspaceResource resource : workspace.getAllResources(false)) { resource.jvmAllClassBundleStreamRecursive().forEach(bundle -> { for (JvmClassInfo jvmClass : bundle.values()) visit(jvmClass); }); } }, threadPool).whenComplete((unused, t) -> { if (t == null) { isReady.setValue(true); } else { logger.error("Call graph initialization failed", t); isReady.setValue(false); } }); } /** * Populate {@link MethodVertex} for all methods in {@link JvmClassInfo#getMethods()}. * * @param jvmClass * Class to visit. */ private void visit(@Nonnull JvmClassInfo jvmClass) { ClassMethodsContainer classMethodsContainer = getClassMethodsContainer(jvmClass); jvmClass.getClassReader().accept(new ClassVisitor(RecafConstants.getAsmVersion()) { @Override public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { MutableMethodVertex methodVertex = (MutableMethodVertex) classMethodsContainer.getVertex(name, descriptor); if (methodVertex == null) { logger.error("Method {}{} was visited, but not present in info for declaring class {}", name, descriptor, jvmClass.getName()); return null; } return new MethodVisitor(RecafConstants.getAsmVersion()) { @Override public void visitEnd() { super.visitEnd(); linkedResolvedCalls(jvmClass, name, descriptor, methodVertex); } @Override public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) { onMethodCalled(jvmClass, methodVertex, opcode, owner, name, descriptor, isInterface); } @Override public void visitInvokeDynamicInsn(String name, String descriptor, Handle bootstrapMethodHandle, Object... bootstrapMethodArguments) { if (!"java/lang/invoke/LambdaMetafactory".equals(bootstrapMethodHandle.getOwner()) || !"metafactory".equals(bootstrapMethodHandle.getName()) || !"(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;".equals(bootstrapMethodHandle.getDesc())) { super.visitInvokeDynamicInsn(name, descriptor, bootstrapMethodHandle, bootstrapMethodArguments); return; } Object handleObj = bootstrapMethodArguments.length == 3 ? bootstrapMethodArguments[1] : null; if (handleObj instanceof Handle handle) { switch (handle.getTag()) { case Opcodes.H_INVOKESPECIAL: case Opcodes.H_INVOKEVIRTUAL: case Opcodes.H_INVOKESTATIC: case Opcodes.H_INVOKEINTERFACE: visitMethodInsn(handle.getTag(), handle.getOwner(), handle.getName(), handle.getDesc(), handle.isInterface()); } return; } super.visitInvokeDynamicInsn(name, descriptor, bootstrapMethodHandle, bootstrapMethodArguments); } }; } }, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); } /** * Called from the {@link MethodVisitor} in {@link #visit(JvmClassInfo)} when a method is visited. *

* This method ensures that {@link #unresolvedReferences unresolved references} are marked as resolved * and the {@link MethodVertex} model is updated when the given method details match a previously * unresolved declaration. * * @param owner * Owner type of the previously unresolved method call. * @param name * Previously unresolved method name. * @param descriptor * Previously unresolved method descriptor. * @param methodVertex * Vertex for the previously unresolved method. */ private void linkedResolvedCalls(@Nonnull JvmClassInfo owner, @Nonnull String name, @Nonnull String descriptor, @Nonnull MutableMethodVertex methodVertex) { // Remove the method from the unresolved declarations map, since it is now resolved. unresolvedDeclarations.remove(owner.getName(), methodVertex.getMethod()); // Skip if the unresolved references map does not contain the given class // or if the set of unresolved calls to this class is empty. String ownerName = owner.getName(); Collection unresolvedCallsToThisClass = unresolvedReferences.getIfPresent(ownerName); if (unresolvedCallsToThisClass.isEmpty()) return; // For each calling context that refers to this class, resolve the reference to this method (method params). for (CallingContext context : unresolvedCallsToThisClass) { MethodRef callingMethod = context.callingMethod(); ClassMethodsContainer callingContainer = getClassMethodsContainer(context.callingClass()); MutableMethodVertex callingVertex = (MutableMethodVertex) callingContainer.getVertex(callingMethod.name(), callingMethod.desc()); if (callingVertex == null) continue; onMethodCalled(context.callingClass(), callingVertex, context.opcode(), ownerName, name, descriptor, context.itf()); } } /** * Replace any unresolved calling contexts that reference an older instance (by name) * with contexts that reference the freshly provided class instance. * * @param updated * Updated class to normalize calling contexts for. */ private void normalizeCallingContexts(@Nonnull JvmClassInfo updated) { String updatedName = updated.getName(); // Iterate over all owners in unresolvedReferences (callee class names) and update contexts for (String owner : new HashSet<>(unresolvedReferences.keySet())) { // Skip if the owner does not match the updated class's name. Collection contexts = unresolvedReferences.getIfPresent(owner); if (contexts.isEmpty()) continue; // For each calling context, if the calling class name matches the updated class's name, // replace the context with a new one that references the fresh class instance. Set snapshot = new HashSet<>(contexts); for (CallingContext ctx : snapshot) { JvmClassInfo calling = ctx.callingClass(); if (calling != updated && updatedName.equals(calling.getName())) { if (contexts.remove(ctx)) { CallingContext newCtx = new CallingContext(updated, ctx.callingMethod(), ctx.opcode(), ctx.itf()); unresolvedReferences.put(owner, newCtx); } } } // If all contexts were removed, remove the owner from the map. if (contexts.isEmpty()) unresolvedReferences.remove(owner); } } /** * Remove any unresolved calling contexts that reference the exact removed instance. * * @param removed * Removed class to remove stale contexts for. */ private void removeStaleCallingContextsForRemovedClass(@Nonnull JvmClassInfo removed) { for (String owner : new HashSet<>(unresolvedReferences.keySet())) { // Skip if the owner does not match the removed class name. Collection contexts = unresolvedReferences.getIfPresent(owner); if (contexts.isEmpty()) continue; Set snapshot = new HashSet<>(contexts); boolean changed = false; for (CallingContext ctx : snapshot) { if (ctx.callingClass() == removed) { if (contexts.remove(ctx)) changed = true; } } if (contexts.isEmpty()) { unresolvedReferences.remove(owner); } else if (changed) { // nothing else required - contexts collection already updated } } } /** * Called from the {@link ClassReader} in {@link #visit(JvmClassInfo)}. * Links the given vertex to the remote {@link MethodVertex} of the resolved method call, * if resolution is a success. *

* When not successful, the call is recorded as an unresolved reference in two directions. *

    *
  • {@link #unresolvedDeclarations Class names --> Missing method declarations}
  • *
  • {@link #unresolvedReferences Class names --> Methods that have calls to missing method declarations
  • *
* * @param callingClass * The class that defines the calling method. * @param callingVertex * The method that is doing the call. * @param opcode * Call opcode. * @param owner * Call owner. * @param name * Method call name. * @param descriptor * Method call descriptor. * @param isInterface * Method interface flag. */ private void onMethodCalled(@Nonnull JvmClassInfo callingClass, @Nonnull MutableMethodVertex callingVertex, int opcode, @Nonnull String owner, @Nonnull String name, @Nonnull String descriptor, boolean isInterface) { MethodRef ref = new MethodRef(owner, name, descriptor); // Resolve the method Result> resolutionResult = resolve(opcode, owner, name, descriptor, isInterface); // Handle result CallingContext callContext = new CallingContext(callingClass, callingVertex.getMethod(), opcode, isInterface); if (resolutionResult.isSuccess()) { // Extract vertex from resolution Resolution resolution = resolutionResult.value(); ClassMethodsContainer resolvedClass = getClassMethodsContainer(resolution.owner().innerValue()); MutableMethodVertex resolvedMethodCallVertex = (MutableMethodVertex) resolvedClass.getVertex(resolution.member().innerValue()); // Link the vertices callingVertex.getCalls().add(resolvedMethodCallVertex); resolvedMethodCallVertex.getCallers().add(callingVertex); // Remove tracked unresolved call if any exist Collection unresolvedWithinOwner = unresolvedDeclarations.getIfPresent(owner); if (unresolvedWithinOwner.remove(ref)) { if (unresolvedWithinOwner.isEmpty()) unresolvedDeclarations.remove(owner); logger.debugging(l -> l.info("Satisfy unresolved call {}", ref)); } // Remove tracking of unresolved declarations/references Collection unresolvedRefsToOwner = unresolvedReferences.getIfPresent(owner); if (unresolvedRefsToOwner.remove(callContext)) { if (unresolvedRefsToOwner.isEmpty()) unresolvedReferences.remove(owner); logger.debugging(l -> l.info("Satisfy unresolved reference from {} to {}", callContext.callingMethod(), ref)); } } else { unresolvedDeclarations.put(owner, ref); unresolvedReferences.put(owner, callContext); logger.debugging(l -> l.warn("Cannot resolve method: {} - {}", ref, resolutionResult.error())); } } /** * @param opcode * Method invoke opcode. * @param owner * Declaring class of method. * @param name * Name of method invoked. * @param descriptor * Descriptor of method invoked. * @param isInterface * Invoke interface flag. * * @return Resolution result of the method within the owner. * The result {@link Result#isError()} will be {@code true} when the {@code owner} could not be found. */ @Nonnull public Result> resolve(int opcode, @Nonnull String owner, @Nonnull String name, @Nonnull String descriptor, boolean isInterface) { // Skip if we cannot resolve owner JvmClassInfo ownerClass = lookup.apply(owner); if (ownerClass == null) return Result.error(ResolutionError.NO_SUCH_METHOD); Result> resolutionResult; LinkedClass linkedOwnerClass = linked(ownerClass); switch (opcode) { case Opcodes.H_INVOKESPECIAL: case Opcodes.INVOKESPECIAL: // Invoke-Special is a direct call, so we do need to do resolving MemberInfo method = linkedOwnerClass.getMethod(name, descriptor); if (method != null) resolutionResult = Result.ok(new Resolution<>(linkedOwnerClass, method, false)); else resolutionResult = Result.error(ResolutionError.NO_SUCH_METHOD); break; case Opcodes.H_INVOKEVIRTUAL: case Opcodes.INVOKEVIRTUAL: resolutionResult = resolver.resolveVirtualMethod(linkedOwnerClass, name, descriptor); break; case Opcodes.H_INVOKEINTERFACE: case Opcodes.INVOKEINTERFACE: resolutionResult = resolver.resolveInterfaceMethod(linkedOwnerClass, name, descriptor); break; case Opcodes.H_INVOKESTATIC: case Opcodes.INVOKESTATIC: resolutionResult = resolver.resolveStaticMethod(linkedOwnerClass, name, descriptor); break; default: throw new IllegalArgumentException("Invalid method opcode: " + opcode); } return resolutionResult; } @Override public void onAddLibrary(@Nonnull Workspace workspace, @Nonnull WorkspaceResource library) { // Visit all library classes library.jvmAllClassBundleStreamRecursive().forEach(bundle -> { for (JvmClassInfo jvmClass : bundle.values()) onNewClass(library, bundle, jvmClass); }); } @Override public void onRemoveLibrary(@Nonnull Workspace workspace, @Nonnull WorkspaceResource library) { // Remove all vertices from library library.jvmAllClassBundleStreamRecursive().forEach(bundle -> { for (JvmClassInfo jvmClass : bundle.values()) onRemoveClass(library, bundle, jvmClass); }); } @Override public void onNewClass(@Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo cls) { visit(cls); } @Override public void onUpdateClass(@Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo oldCls, @Nonnull JvmClassInfo newCls) { onRemoveClass(resource, bundle, oldCls); normalizeCallingContexts(newCls); onNewClass(resource, bundle, newCls); } @Override public void onRemoveClass(@Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo cls) { String clsName = cls.getName(); ClassMethodsContainer container = getClassMethodsContainer(cls); Set unresolvedDeclarationsWithinOwner = unresolvedDeclarations.get(clsName); for (MethodVertex vertex : container.getVertices()) { MethodRef ref = vertex.getMethod(); if (vertex instanceof MutableMethodVertex mutableMethodVertex) { MethodMember resolvedMethod = vertex.getResolvedMethod(); if (resolvedMethod != null) { // Mark any inbound calls to the removed method as an unresolved references. Collection callers = mutableMethodVertex.getCallers(); for (MethodVertex caller : callers) { MethodRef callerRef = caller.getMethod(); String callerName = callerRef.owner(); ClassPathNode path = workspace.findClass(callerName); if (path != null) { // All callers to the removed method should be marked as an unresolved reference. int op = AsmInsnUtil.getInvokeForMethod(resolvedMethod.getAccess()); CallingContext context = new CallingContext(path.getValue().asJvmClass(), callerRef, op, false); unresolvedReferences.put(clsName, context); } } // If the removed method is called, then we need to mark the method as an unresolved declaration. if (!callers.isEmpty()) unresolvedDeclarations.put(clsName, ref); } // Prune connections to other vertices, since this method is now removed. mutableMethodVertex.prune(); // Also mark the method as an unresolved declaration, since it is now removed. unresolvedDeclarationsWithinOwner.add(ref); } else { logger.warn("Could not prune reference: {}", ref); } } // Remove from maps classToLinkerType.remove(cls); classToMethodsContainer.remove(cls); // Remove any unresolved calling contexts that reference the removed instance (stale data) removeStaleCallingContextsForRemovedClass(cls); } /** * @return Map of classes that could not be resolved, to method declarations observed being made to them. */ @Nonnull @VisibleForTesting MultiMap> getUnresolvedDeclarations() { return unresolvedDeclarations; } /** * Models the calling context to some method. * * @param callingClass * Class that defines the calling method. * @param callingMethod * The calling method information. * @param opcode * The outgoing call opcode. * @param itf * The outgoing call {@code isInterface} flag. */ private record CallingContext(@Nonnull JvmClassInfo callingClass, @Nonnull MethodRef callingMethod, int opcode, boolean itf) {} /** * Mutable impl of {@link MethodVertex}. */ static class MutableMethodVertex implements MethodVertex { private final Set callers = Collections.synchronizedSet(new HashSet<>()); private final Set calls = Collections.synchronizedSet(new HashSet<>()); private final MethodRef method; private final MethodMember resolvedMethod; MutableMethodVertex(@Nonnull MethodRef method, @Nonnull MethodMember resolvedMethod) { this.method = method; this.resolvedMethod = resolvedMethod; } /** * Removes this method vertex from connected vertices. */ private void prune() { // Remove this vertex as a caller from the methods we call for (MethodVertex out : getCalls()) { if (out instanceof MutableMethodVertex) { out.getCallers().remove(this); } } // Remove this vertex as a destination from methods that call us for (MethodVertex in : getCallers()) { if (in instanceof MutableMethodVertex) { in.getCalls().remove(this); } } } @Nonnull @Override public MethodRef getMethod() { return method; } @Nullable @Override public MethodMember getResolvedMethod() { return resolvedMethod; } @Nonnull @Override public Collection getCallers() { return callers; } @Nonnull @Override public Collection getCalls() { return calls; } @Override public String toString() { return method.toString(); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; MutableMethodVertex vertex = (MutableMethodVertex) o; return method.equals(vertex.method); } @Override public int hashCode() { return method.hashCode(); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/callgraph/CallGraphConfig.java ================================================ package software.coley.recaf.services.callgraph; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link CallGraph}. * * @author Matt Coley */ @ApplicationScoped public class CallGraphConfig extends BasicConfigContainer implements ServiceConfig { @Inject public CallGraphConfig() { super(ConfigGroups.SERVICE_ANALYSIS, CallGraphService.SERVICE_ID + CONFIG_SUFFIX); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/callgraph/CallGraphService.java ================================================ package software.coley.recaf.services.callgraph; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.analytics.logging.DebuggingLogger; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.cdi.EagerInitialization; import software.coley.recaf.services.Service; import software.coley.recaf.services.workspace.WorkspaceCloseListener; import software.coley.recaf.services.workspace.WorkspaceManager; import software.coley.recaf.services.workspace.WorkspaceOpenListener; import software.coley.recaf.workspace.model.Workspace; import java.util.concurrent.CompletableFuture; /** * Service offering the creation of {@link CallGraph call graphs} for workspaces. * * @author Matt Coley * @see CallGraph */ @EagerInitialization @ApplicationScoped public class CallGraphService implements Service { public static final String SERVICE_ID = "graph-calls"; private static final DebuggingLogger logger = Logging.get(CallGraphService.class); private final CallGraphConfig config; private CallGraph currentWorkspaceGraph; /** * @param workspaceManager * Manager to register listeners for, in order to manage a shared graph for the current workspace. * @param config * Graphing config options. */ @Inject public CallGraphService(@Nonnull WorkspaceManager workspaceManager, @Nonnull CallGraphConfig config) { this.config = config; ListenerHost host = new ListenerHost(); workspaceManager.addWorkspaceOpenListener(host); workspaceManager.addWorkspaceCloseListener(host); } /** * Creates a new call graph for the given workspace. * Before you use the graph, you will need to call {@link CallGraph#initialize()}. * * @param workspace * Workspace to pull classes from. * * @return New call graph model for the given workspace. */ @Nonnull public CallGraph newCallGraph(@Nonnull Workspace workspace) { return new CallGraph(workspace); } /** * @return Call graph model for the {@link WorkspaceManager#getCurrent() current workspace} * or {@code null} if no workspace is currently open. */ @Nullable public CallGraph getCurrentWorkspaceCallGraph() { CallGraph graph = currentWorkspaceGraph; // Lazily initialize the graph so that we don't do a full graph immediately when the workspace is opened. // It will only initialize when a user needs to use it. if (!graph.isInitialized()) CompletableFuture.runAsync(graph::initialize); return graph; } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public CallGraphConfig getServiceConfig() { return config; } private class ListenerHost implements WorkspaceOpenListener, WorkspaceCloseListener { @Override public void onWorkspaceOpened(@Nonnull Workspace workspace) { currentWorkspaceGraph = newCallGraph(workspace); } @Override public void onWorkspaceClosed(@Nonnull Workspace workspace) { currentWorkspaceGraph = null; } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/callgraph/ClassLookup.java ================================================ package software.coley.recaf.services.callgraph; import jakarta.annotation.Nonnull; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.workspace.model.Workspace; import java.util.function.Function; /** * Lookup for convenience. * * @author Matt Coley */ public class ClassLookup implements Function { private final Workspace workspace; /** * @param workspace * Workspace to pull from. */ public ClassLookup(@Nonnull Workspace workspace) { this.workspace = workspace; } @Override public JvmClassInfo apply(String name) { if (name == null) return null; ClassPathNode classPath = workspace.findJvmClass(name); if (classPath == null) classPath = workspace.findLatestVersionedJvmClass(name); if (classPath == null) return null; return classPath.getValue().asJvmClass(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/callgraph/ClassMethodsContainer.java ================================================ package software.coley.recaf.services.callgraph; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.member.MethodMember; import java.util.Collection; import java.util.Collections; import java.util.IdentityHashMap; import java.util.Map; /** * Representation of a {@link JvmClassInfo} for {@link CallGraph}. * All the {@link MethodMember} values of the {@link JvmClassInfo#getMethods()} can be mapped to vertices via * {@link #getVertex(MethodMember)}. * * @author Matt Coley */ public class ClassMethodsContainer { private final Map methodVertices = Collections.synchronizedMap(new IdentityHashMap<>()); private final JvmClassInfo jvmClass; /** * @param jvmClass * Class to wrap. */ public ClassMethodsContainer(@Nonnull JvmClassInfo jvmClass) { this.jvmClass = jvmClass; } /** * @return Wrapped {@link JvmClassInfo}. */ @Nonnull public JvmClassInfo getJvmClass() { return jvmClass; } /** * @return Collection of method vertices within this class. */ @Nonnull public Collection getVertices() { return methodVertices.values(); } /** * @param name * Method name. * @param descriptor * Method descriptor. * * @return Method vertex of the declared method. * {@code null} when no method by the given name/desc exist in this class. */ @Nullable public MethodVertex getVertex(@Nonnull String name, @Nonnull String descriptor) { MethodMember member = jvmClass.getDeclaredMethod(name, descriptor); if (member == null) return null; return getVertex(member); } /** * @param member * Member declaration from the associated {@link JvmClassInfo}. * * @return Method vertex of the declared method. * * @throws IllegalArgumentException * When the member declaration does not belong to the associated {@link JvmClassInfo}. */ @Nonnull public MethodVertex getVertex(@Nonnull MethodMember member) throws IllegalArgumentException { if (member.getDeclaringClass() != jvmClass) throw new IllegalArgumentException("Member does not belong to class from this vertex"); return methodVertices.computeIfAbsent(member, m -> new CallGraph.MutableMethodVertex( new MethodRef(jvmClass.getName(), member.getName(), member.getDescriptor()), member) ); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/callgraph/LinkedClass.java ================================================ package software.coley.recaf.services.callgraph; import dev.xdark.jlinker.ClassInfo; import dev.xdark.jlinker.MemberInfo; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.analytics.logging.DebuggingLogger; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.util.MemoizedFunctions; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.function.BiFunction; import java.util.function.Function; /** * JLinker wrapper of {@link JvmClassInfo}. * * @author Matt Coley */ public class LinkedClass implements ClassInfo { private static final DebuggingLogger logger = Logging.get(LinkedClass.class); private final JvmClassInfo info; private final Function superClassLookup; private final Function, List>> interfacesLookup; private final BiFunction> fieldLookup; private final BiFunction> methodLookup; public LinkedClass(@Nonnull ClassLookup lookup, @Nonnull JvmClassInfo info) { this.info = info; superClassLookup = MemoizedFunctions.memoize((String superName) -> { JvmClassInfo superClass = lookup.apply(superName); if (superClass == null) { logger.debugging(l -> l.warn("Lookup failed for super-class: {}", superName)); return null; } return new LinkedClass(lookup, superClass); }); interfacesLookup = MemoizedFunctions.memoize((List interfaces) -> { if (interfaces.isEmpty()) return Collections.emptyList(); List> values = new ArrayList<>(); for (String itf : interfaces) { JvmClassInfo itfInfo = lookup.apply(itf); if (itfInfo == null) logger.debugging(l -> l.warn("Lookup failed for interface: {}", itf)); else values.add(new LinkedClass(lookup, itfInfo)); } return values; }); fieldLookup = MemoizedFunctions.memoize((name, descriptor) -> { FieldMember declaredField = info.getDeclaredField(name, descriptor); if (declaredField == null) { logger.debugging(l -> l.warn("Missing declared field: {} {}", descriptor, name)); return null; } return new MemberInfo<>() { @Override public FieldMember innerValue() { return declaredField; } @Override public int accessFlags() { return declaredField.getAccess(); } @Override public boolean isPolymorphic() { return false; } }; }); methodLookup = MemoizedFunctions.memoize((name, descriptor) -> { MethodMember declaredMethod = info.getDeclaredMethod(name, descriptor); if (declaredMethod == null) { logger.debugging(l -> l.warn("Missing declared method: {}{}", name, descriptor)); return null; } return new MemberInfo<>() { @Override public MethodMember innerValue() { return declaredMethod; } @Override public int accessFlags() { return declaredMethod.getAccess(); } @Override public boolean isPolymorphic() { return false; } }; }); } @Override public JvmClassInfo innerValue() { return info; } @Override public int accessFlags() { return info.getAccess(); } @Nullable @Override public ClassInfo superClass() { String superName = info.getSuperName(); if (superName == null) return null; return superClassLookup.apply(superName); } @Nonnull @Override public List> interfaces() { return interfacesLookup.apply(info.getInterfaces()); } @Override public MemberInfo getMethod(String name, String descriptor) { return methodLookup.apply(name, descriptor); } @Override public MemberInfo getField(String name, String descriptor) { return fieldLookup.apply(name, descriptor); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/callgraph/MethodRef.java ================================================ package software.coley.recaf.services.callgraph; import jakarta.annotation.Nonnull; /** * Basic method outline. * * @param owner * Method reference owner. * @param name * Method name. * @param desc * Method descriptor. * * @author Amejonah */ public record MethodRef(@Nonnull String owner, @Nonnull String name, @Nonnull String desc) {} ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/callgraph/MethodVertex.java ================================================ package software.coley.recaf.services.callgraph; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.member.MethodMember; import java.util.Collection; /** * Outline of a method vertex. * * @author Amejonah */ public interface MethodVertex { /** * @return Basic method details. */ @Nonnull MethodRef getMethod(); /** * @return Declaration of the method. Only known if the method vertex has been resolved. */ @Nullable MethodMember getResolvedMethod(); /** * @return Methods that call this method. */ @Nonnull Collection getCallers(); /** * @return Methods this method calls. */ @Nonnull Collection getCalls(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/comment/ClassComments.java ================================================ package software.coley.recaf.services.comment; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.MethodMember; import java.time.Instant; /** * Outline of a container for comments for a class and its contents. * * @author Matt Coley */ public interface ClassComments { /** * @return Time of the first created comment. */ @Nonnull Instant getCreationTime(); /** * @return Time of the last comment update. */ @Nonnull Instant getLastUpdatedTime(); /** * @return {@code true} when any comments exist on this class, or any of its declared members. */ boolean hasComments(); /** * @return Class comment, if any. */ @Nullable String getClassComment(); /** * @param comment * New class comment, or {@code null} to remove an existing comment. */ void setClassComment(@Nullable String comment); /** * @param member * Field to look up. * * @return Field comment, if any. */ @Nullable default String getFieldComment(@Nonnull FieldMember member) { return getFieldComment(member.getName(), member.getDescriptor()); } /** * @param name * Field name. * @param descriptor * Field descriptor. * * @return Field comment, if any. */ @Nullable String getFieldComment(@Nonnull String name, @Nonnull String descriptor); /** * @param member * Method to look up. * * @return Method comment, if any. */ @Nullable default String getMethodComment(@Nonnull MethodMember member) { return getMethodComment(member.getName(), member.getDescriptor()); } /** * @param name * Method name. * @param descriptor * Method descriptor. * * @return Method comment, if any. */ @Nullable String getMethodComment(@Nonnull String name, @Nonnull String descriptor); /** * @param member * Field to assign comment to. * @param comment * New field comment, or {@code null} to remove an existing comment. */ default void setFieldComment(@Nonnull FieldMember member, @Nullable String comment) { setFieldComment(member.getName(), member.getDescriptor(), comment); } /** * @param name * Field name. * @param descriptor * Field descriptor. * @param comment * New field comment, or {@code null} to remove an existing comment. */ void setFieldComment(@Nonnull String name, @Nonnull String descriptor, @Nullable String comment); /** * @param member * Method to assign comment to. * @param comment * New method comment, or {@code null} to remove an existing comment. */ default void setMethodComment(@Nonnull MethodMember member, @Nullable String comment) { setMethodComment(member.getName(), member.getDescriptor(), comment); } /** * @param name * Method name. * @param descriptor * Method descriptor. * @param comment * New method comment, or {@code null} to remove an existing comment. */ void setMethodComment(@Nonnull String name, @Nonnull String descriptor, @Nullable String comment); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/comment/CommentContainerListener.java ================================================ package software.coley.recaf.services.comment; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.behavior.PrioritySortable; import software.coley.recaf.path.ClassPathNode; /** * Listener for receiving updates when comment containers are removed. * * @author Matt Coley */ public interface CommentContainerListener extends PrioritySortable { /** * @param path * Path to class. * @param comments * Newly made comment container for the class. */ default void onClassContainerCreated(@Nonnull ClassPathNode path, @Nullable ClassComments comments) {} /** * @param path * Path to class. * @param comments * Removed comment container of the class. */ default void onClassContainerRemoved(@Nonnull ClassPathNode path, @Nullable ClassComments comments) {} } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/comment/CommentInsertingVisitor.java ================================================ package software.coley.recaf.services.comment; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.ClassWriter; import org.objectweb.asm.FieldVisitor; import org.objectweb.asm.MethodVisitor; import software.coley.recaf.RecafConstants; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.path.ClassPathNode; /** * A class visitor which inserts {@link CommentKey} annotations into the given class. * * @author Matt Coley */ public class CommentInsertingVisitor extends ClassVisitor { private final ClassComments comments; private final ClassPathNode classPath; private int insertions; /** * @param comments * Comment container for the class. * @param classPath * Path to class in its containing workspace. * @param cv * Delegate class-visitor. */ public CommentInsertingVisitor(@Nonnull ClassComments comments, @Nonnull ClassPathNode classPath, @Nullable ClassVisitor cv) { super(RecafConstants.getAsmVersion(), cv); this.comments = comments; this.classPath = classPath; } /** * @return Number of inserted comment annotations. */ public int getInsertions() { return insertions; } @Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { super.visit(version, access, name, signature, superName, interfaces); // Insert key for comment String comment = comments.getClassComment(); if (comment != null) { CommentKey key = CommentKey.id(classPath); visitAnnotation(key.annotationDescriptor(), true); insertions++; } } @Override public FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) { FieldVisitor fv = super.visitField(access, name, descriptor, signature, value); // Insert key for comment String comment = comments.getFieldComment(name, descriptor); if (comment != null) { FieldMember field = classPath.getValue().getDeclaredField(name, descriptor); if (field != null) { CommentKey key = CommentKey.id(classPath.child(field)); fv.visitAnnotation(key.annotationDescriptor(), true); insertions++; } } return fv; } @Override public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions); // Insert key for comment String comment = comments.getMethodComment(name, descriptor); if (comment != null) { MethodMember method = classPath.getValue().getDeclaredMethod(name, descriptor); if (method != null) { CommentKey key = CommentKey.id(classPath.child(method)); mv = new CommentAppender(mv, key); insertions++; } } return mv; } /** * This class exists to facilitate optimal use of {@link ClassWriter#ClassWriter(ClassReader, int)} * (See: {@code MethodWriter#canCopyMethodAttributes(ClassReader, boolean, boolean, int, int, int)}). *
* Methods are copied as-is unless there is a custom subtype of {@link MethodVisitor} used, hence this existing. * Any methods that aren't having comments inserted then get copied over much faster than if they were rebuilt * from scratch. */ private static class CommentAppender extends MethodVisitor { private final CommentKey key; private CommentAppender(@Nullable MethodVisitor mv, @Nonnull CommentKey key) { super(RecafConstants.getAsmVersion(), mv); this.key = key; } @Override public void visitEnd() { super.visitEnd(); visitAnnotation(key.annotationDescriptor(), true); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/comment/CommentKey.java ================================================ package software.coley.recaf.services.comment; import jakarta.annotation.Nonnull; import software.coley.recaf.info.FileInfo; import software.coley.recaf.info.member.ClassMember; import software.coley.recaf.info.properties.builtin.InputFilePathProperty; import software.coley.recaf.path.ClassMemberPathNode; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.path.PathNode; import software.coley.recaf.services.tutorial.TutorialWorkspaceResource; import software.coley.recaf.util.StringUtil; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.resource.WorkspaceDirectoryResource; import software.coley.recaf.workspace.model.resource.WorkspaceFileResource; import software.coley.recaf.workspace.model.resource.WorkspaceRemoteVmResource; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.nio.file.Path; import java.util.Objects; /** * A key to uniquely identify a comment in a workspace. * * @param workspaceHash * Hash of workspace. * @param pathHash * Hash of path where the comment resides. * * @author Matt Coley */ public record CommentKey(int workspaceHash, int pathHash) { /** The current username of the system should be unique enough to use as a salt, and should always exist. */ private static final int SALT = System.getProperty("user.name", "recaf").hashCode(); /** * @param path * Path in a workspace. * * @return Key for path location. */ @Nonnull public static CommentKey id(@Nonnull PathNode path) { int workspaceHash = hashWorkspace(Objects.requireNonNull(path.getValueOfType(Workspace.class), "Path did not contain 'workspace' node")); int pathHash = hashPath(path); return new CommentKey(workspaceHash, pathHash); } /** * @param workspace * Workspace input. * * @return Hash of workspace. */ public static int hashWorkspace(@Nonnull Workspace workspace) { String input = workspaceInput(workspace); return input.hashCode(); } /** * @param path * Path input. * * @return Hash of path. */ public static int hashPath(@Nonnull PathNode path) { // We only want to hash portions of a path that we support. // - Classes // - Members (Fields/methods) int baseHash; if (path instanceof ClassPathNode classPath) { baseHash = classPath.getValue().getName().hashCode(); } else if (path instanceof ClassMemberPathNode memberPath) { ClassMember member = memberPath.getValue(); int hash = 31 * member.getName().hashCode() + member.getDescriptor().hashCode(); PathNode parent = path.getParent(); if (parent != null) hash = 31 * hashPath(parent) + hash; baseHash = hash; } else { // Unsupported path content. return -1; } // Because class and member names are known to the authors of the code, they could predict the hashes. // Thus, we salt the hash with information from the local system, which cannot realistically be predicted. // This is a bit overkill since all that they could do is insert the same annotation we'd normally create, // but because the actual comment data is stored externally they can't insert unintended comments. return SALT + 31 * baseHash; } /** * @param workspace * Workspace instance. * * @return Path of input from workspace. */ @Nonnull static String workspaceInput(@Nonnull Workspace workspace) { // The workspace hashCode reflects the state of its contents (which updates as the user makes changes) // We want to generate a key based on some info that is consistent and re-producible over time. // Ideally we can get a sort of 'source' of the loaded content from each primary resource of the given workspace. WorkspaceResource resource = workspace.getPrimaryResource(); switch (resource) { case WorkspaceFileResource fileResource -> { // Hash based on file path of input, or file name if full path not known. FileInfo fileInfo = fileResource.getFileInfo(); Path path = InputFilePathProperty.get(fileInfo); return path == null ? fileInfo.getName() : path.toString(); } case WorkspaceDirectoryResource directoryResource -> { // Hash based on directory path of input. return directoryResource.getDirectoryPath().toString(); } case WorkspaceRemoteVmResource remoteVmResource -> { // Hash based on VM id, which is generally consistent when re-running the same application. return remoteVmResource.getVirtualMachine().id(); } case TutorialWorkspaceResource tutorialResource -> { // Constant name for the tutorial. return TutorialWorkspaceResource.COMMENT_KEY; } default -> { } } // Unsupported workspace content. return "unknown-workspace"; } /** * @return Annotation descriptor of this comment key. */ @Nonnull public String annotationDescriptor() { String paddedWorkspaceHash = StringUtil.fillLeft(8, "0", Integer.toHexString(workspaceHash)); String paddedPathHash = StringUtil.fillLeft(8, "0", Integer.toHexString(pathHash)); return "LRecafComment_" + paddedWorkspaceHash + '_' + paddedPathHash + ';'; } @Override public String toString() { return annotationDescriptor(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/comment/CommentManager.java ================================================ package software.coley.recaf.services.comment; import com.google.gson.Gson; import com.google.gson.TypeAdapter; import com.google.gson.TypeAdapterFactory; import com.google.gson.reflect.TypeToken; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; import org.slf4j.Logger; import software.coley.collections.Unchecked; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.behavior.PrioritySortable; import software.coley.recaf.cdi.EagerInitialization; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.member.ClassMember; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.path.ClassMemberPathNode; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.services.Service; import software.coley.recaf.services.decompile.DecompilerManager; import software.coley.recaf.services.decompile.filter.JvmBytecodeFilter; import software.coley.recaf.services.decompile.filter.OutputTextFilter; import software.coley.recaf.services.file.RecafDirectoriesConfig; import software.coley.recaf.services.json.GsonProvider; import software.coley.recaf.services.mapping.BasicMappingsRemapper; import software.coley.recaf.services.mapping.MappingApplicationListener; import software.coley.recaf.services.mapping.MappingListeners; import software.coley.recaf.services.mapping.MappingResults; import software.coley.recaf.services.mapping.Mappings; import software.coley.recaf.services.tutorial.TutorialWorkspaceResource; import software.coley.recaf.services.workspace.WorkspaceManager; import software.coley.recaf.util.StringUtil; import software.coley.recaf.util.TestEnvironment; import software.coley.recaf.workspace.model.Workspace; import java.nio.file.Files; import java.nio.file.Path; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; /** * Manager for comment tracking on {@link ClassInfo} and {@link ClassMember} content in workspaces. * * @author Matt Coley */ @EagerInitialization // We need to eagerly init so that we can register hooks in decompilation @ApplicationScoped public class CommentManager implements Service, CommentUpdateListener, CommentContainerListener { public static final String SERVICE_ID = "comments"; private static final Logger logger = Logging.get(CommentManager.class); /** Map of workspace comment impls used to fire off listener calls, delegates to persist map entries */ private final Map delegatingMap = new ConcurrentHashMap<>(); /** Map of workspace comment impls modeling only data. Used for persistence. */ private final Map persistMap = new ConcurrentHashMap<>(); private final List commentUpdateListeners = new CopyOnWriteArrayList<>(); private final List commentContainerListeners = new CopyOnWriteArrayList<>(); private final WorkspaceManager workspaceManager; private final RecafDirectoriesConfig directoriesConfig; private final CommentManagerConfig config; private final GsonProvider gsonProvider; @Inject public CommentManager(@Nonnull DecompilerManager decompilerManager, @Nonnull WorkspaceManager workspaceManager, @Nonnull MappingListeners mappingListeners, @Nonnull GsonProvider gsonProvider, @Nonnull RecafDirectoriesConfig directoriesConfig, @Nonnull CommentManagerConfig config) { this.workspaceManager = workspaceManager; this.gsonProvider = gsonProvider; this.directoriesConfig = directoriesConfig; this.config = config; // Register input filter to insert comment identifier annotations. JvmBytecodeFilter keyInsertingFilter = new JvmBytecodeFilter() { @Nonnull @Override public byte[] filter(@Nonnull Workspace workspace, @Nonnull JvmClassInfo initialClassInfo, @Nonnull byte[] bytecode) { // Skip if comment insertion is disabled. if (!config.getEnableCommentDisplay().hasValue()) return bytecode; // Skip if there are no comments in the workspace. WorkspaceComments comments = getWorkspaceComments(workspace); if (comments == null) return bytecode; // Skip if the class is not found in the workspace. ClassPathNode classPath = workspace.findClass(initialClassInfo.getName()); if (classPath == null) return bytecode; // Skip if there are no comments for the class. ClassComments classComments = comments.getClassComments(classPath); if (classComments == null) return bytecode; // Adapt with comment annotations. ClassReader reader = new ClassReader(bytecode); ClassWriter writer = new ClassWriter(reader, 0); CommentInsertingVisitor inserter = new CommentInsertingVisitor(classComments, classPath, writer); reader.accept(inserter, initialClassInfo.getClassReaderFlags()); if (inserter.getInsertions() > 0) return writer.toByteArray(); return bytecode; } }; OutputTextFilter keyReplacementFilter = new OutputTextFilter() { private static final String KEY = "@RecafComment_"; @Nonnull @Override public String filter(@Nonnull Workspace workspace, @Nonnull ClassInfo classInfo, @Nonnull String code) { int codeLength = code.length(); int keyLength = KEY.length(); int i = codeLength; // Get class comments container if it exists. ClassPathNode classPath = workspace.findClass(classInfo.getName()); if (classPath == null) return code; WorkspaceComments comments = persistMap.get(CommentKey.workspaceInput(workspace)); if (comments == null) return code; ClassComments classComments = comments.getClassComments(classPath); if (classComments == null) return code; do { int commentIndex = code.lastIndexOf(KEY, i); if (commentIndex < 0) break; i = commentIndex - 1; // Move backwards // Extract path key text from annotation name. int keyValueStart = commentIndex + keyLength; int endWorkspaceKey = Math.min(codeLength, keyValueStart + 8); int endPathKey = Math.min(codeLength, keyValueStart + 17); String pathKeyText = code.substring(endWorkspaceKey + 1, endPathKey); try { // The values are integer hashCodes converted to unsigned hex. // Casting back will put em back to the expected value. // Initial hash: -1845811502 // Unsigned: 2449155794 (out of int bounds) // Casting will return to the original value. int pathKey = (int) Long.parseLong(pathKeyText, 16); // Lookup what path in the class correlates to the path-key. String comment = null; int pathHash = CommentKey.hashPath(classPath); if (pathHash == pathKey) { comment = classComments.getClassComment(); } else { for (FieldMember field : classInfo.getFields()) { pathHash = CommentKey.hashPath(classPath.child(field)); if (pathHash == pathKey) { comment = classComments.getFieldComment(field); break; } } if (comment == null) { for (MethodMember method : classInfo.getMethods()) { pathHash = CommentKey.hashPath(classPath.child(method)); if (pathHash == pathKey) { comment = classComments.getMethodComment(method); break; } } } } // Replace the annotation. String replacement; if (comment == null) { replacement = ""; } else { int wordWrapLimit = config.getWordWrappingLimit().getValue(); if (comment.length() > wordWrapLimit) comment = StringUtil.wordWrap(comment, wordWrapLimit); if (comment.contains("\n")) { // The indent starts after the '\n' so that in cases where there isn't a '\n' found we // will consistently insert one. int indentBegin = Math.max(0, code.lastIndexOf("\n", commentIndex) + 1); String indent = '\n' + code.substring(indentBegin, commentIndex); StringBuilder sb = new StringBuilder("/**"); comment.lines().forEach(line -> sb.append(indent).append(" * ").append(line)); sb.append(indent).append(" */"); replacement = sb.toString(); } else { replacement = "/** " + comment + " */"; } } code = StringUtil.replaceRange(code, commentIndex, commentIndex + 31, replacement); } catch (NumberFormatException ignored) { // Bogus anno } } while (true); return code; } }; decompilerManager.addJvmBytecodeFilter(keyInsertingFilter); decompilerManager.addOutputTextFilter(keyReplacementFilter); // Restore any saved comments from disk. loadComments(); // Register mapping listeners so that when types & members are renamed the comments are migrated. mappingListeners.addMappingApplicationListener(new MappingApplicationListener() { @Override public void onPreApply(@Nonnull Workspace workspace, @Nonnull MappingResults mappingResults) { // Mappings must apply to the current workspace. WorkspaceComments comments = getCurrentWorkspaceComments(); if (comments == null) return; if (workspaceManager.getCurrent() != workspace) return; // There are comments that may need to be updated. Mappings mappings = mappingResults.getMappings(); BasicMappingsRemapper remapper = new BasicMappingsRemapper(mappings); mappingResults.streamPreToPostMappingPaths().forEach(pair -> { ClassPathNode preMapped = pair.getLeft(); ClassPathNode postMapped = pair.getRight(); // Skip if class has no comments. ClassComments classComments = comments.getClassComments(preMapped); if (classComments == null) return; // If the class name changed, we will want to migrate the comment to a new target container. ClassInfo preClassInfo = preMapped.getValue(); String preClassName = preClassInfo.getName(); ClassComments targetComments; boolean isNewClassContainer; if (!preClassName.equals(postMapped.getValue().getName())) { comments.deleteClassComments(preMapped); targetComments = comments.getOrCreateClassComments(postMapped); targetComments.setClassComment(classComments.getClassComment()); isNewClassContainer = true; } else { targetComments = classComments; isNewClassContainer = false; } // Migrate field comments. for (FieldMember field : preClassInfo.getFields()) { String fieldName = field.getName(); String fieldDesc = field.getDescriptor(); // If the field is mapped, or the class is mapped, migrate to the new definition. String targetFieldName = mappings.getMappedFieldName(preClassName, fieldName, fieldDesc); if (targetFieldName == null && isNewClassContainer) targetFieldName = fieldName; if (targetFieldName == null) continue; String comment = classComments.getFieldComment(fieldName, fieldDesc); if (comment != null) { // Clear old comment, set in target container with up-to-date field declaration. if (!isNewClassContainer) classComments.setFieldComment(fieldName, fieldDesc, null); targetComments.setFieldComment(targetFieldName, remapper.mapDesc(fieldDesc), comment); } } // Migrate method comments. for (MethodMember method : preClassInfo.getMethods()) { String methodName = method.getName(); String methodDesc = method.getDescriptor(); // If the method mapped, or the class is mapped, migrate to the new definition. String targetMethodName = mappings.getMappedMethodName(preClassName, methodName, methodDesc); if (targetMethodName == null && isNewClassContainer) targetMethodName = methodName; if (targetMethodName == null) continue; String comment = classComments.getMethodComment(methodName, methodDesc); if (comment != null) { // Clear old comment, set in target container with up-to-date method declaration. if (!isNewClassContainer) classComments.setMethodComment(methodName, methodDesc, null); targetComments.setMethodComment(targetMethodName, remapper.mapMethodDesc(methodDesc), comment); } } }); } @Override public void onPostApply(@Nonnull Workspace workspace, @Nonnull MappingResults mappingResults) { // no-op } }); // Register serialization support for the persistent comment models. gsonProvider.addTypeAdapterFactory(new TypeAdapterFactory() { @Override @SuppressWarnings("unchecked") public TypeAdapter create(@Nonnull Gson gson, @Nonnull TypeToken type) { if (WorkspaceComments.class.equals(type.getRawType())) return (TypeAdapter) gson.getAdapter(PersistWorkspaceComments.class); else if (ClassComments.class.equals(type.getRawType())) return (TypeAdapter) gson.getAdapter(PersistClassComments.class); return null; } }); } /** * Loads comments from disk. */ private void loadComments() { // Skip loading in test environment if (TestEnvironment.isTestEnv()) return; try { // TODO: Its not ideal having all comments across all workspaces loaded at once // - Not a big deal right now since I doubt most users will utilize this feature much Gson gson = gsonProvider.getGson(); Path commentsDirectory = getCommentsDirectory(); Path store = commentsDirectory.resolve("comments.json"); if (Files.exists(store)) { String json = Files.readString(store); var deserialized = gson.fromJson(json, new TypeToken>() {}); persistMap.putAll(deserialized); } } catch (Throwable t) { logger.error("Failed to load comments", t); } } /** * Persists comments to disk when shutdown is observed. */ @PreDestroy private void onShutdown() { // Skip persist in test environment. if (TestEnvironment.isTestEnv()) return; // Do not persist the tutorial workspace comments. persistMap.remove(TutorialWorkspaceResource.COMMENT_KEY); // Remove entries that are empty. Set empty = new HashSet<>(); persistMap.forEach((key, workspaceComments) -> { if (workspaceComments.classKeys().isEmpty()) { empty.add(key); } else { boolean isEmpty = true; for (ClassComments classComments : workspaceComments) { if (classComments.hasComments()) { isEmpty = false; break; } } if (isEmpty) empty.add(key); } }); empty.forEach(persistMap::remove); try { Gson gson = gsonProvider.getGson(); String serialized = gson.toJson(persistMap); Path commentsDirectory = getCommentsDirectory(); if (!Files.isDirectory(commentsDirectory)) Files.createDirectories(commentsDirectory); Path store = commentsDirectory.resolve("comments.json"); Files.writeString(store, serialized); } catch (Throwable t) { logger.error("Failed to save comments", t); } } @Override public void onClassCommentUpdated(@Nonnull ClassPathNode path, @Nullable String comment) { Unchecked.checkedForEach(commentUpdateListeners, listener -> listener.onClassCommentUpdated(path, comment), (listener, t) -> logger.error("Exception thrown when updating class comment", t)); } @Override public void onFieldCommentUpdated(@Nonnull ClassMemberPathNode path, @Nullable String comment) { Unchecked.checkedForEach(commentUpdateListeners, listener -> listener.onFieldCommentUpdated(path, comment), (listener, t) -> logger.error("Exception thrown when updating field comment", t)); } @Override public void onMethodCommentUpdated(@Nonnull ClassMemberPathNode path, @Nullable String comment) { Unchecked.checkedForEach(commentUpdateListeners, listener -> listener.onMethodCommentUpdated(path, comment), (listener, t) -> logger.error("Exception thrown when updating method comment", t)); } @Override public void onClassContainerCreated(@Nonnull ClassPathNode path, @Nullable ClassComments comments) { Unchecked.checkedForEach(commentContainerListeners, listener -> listener.onClassContainerCreated(path, comments), (listener, t) -> logger.error("Exception thrown when creating class comment container", t)); } @Override public void onClassContainerRemoved(@Nonnull ClassPathNode path, @Nullable ClassComments comments) { Unchecked.checkedForEach(commentContainerListeners, listener -> listener.onClassContainerRemoved(path, comments), (listener, t) -> logger.error("Exception thrown when removing class comment container", t)); } /** * @param workspace * Workspace to check for comments. * * @return Comments container for the given workspace, creating one if none existed. */ @Nonnull public WorkspaceComments getOrCreateWorkspaceComments(@Nonnull Workspace workspace) { WorkspaceComments comments = getWorkspaceComments(workspace); if (comments == null) { // No existing comments found, lets create them. // - One entry for persistence // - One entry for listener callbacks, delegating to the persist model String input = CommentKey.workspaceInput(workspace); PersistWorkspaceComments persistComments = persistMap.computeIfAbsent(input, i -> new PersistWorkspaceComments()); DelegatingWorkspaceComments delegatingComments = newDelegatingWorkspaceComments(workspace, persistComments); delegatingMap.put(input, delegatingComments); // We want to yield the delegating model for listener support. comments = delegatingComments; } return comments; } /** * @param workspace * Workspace to check for comments. * * @return Comments container for the given workspace, if any comments exist. * If there are no comments, then {@code null}. */ @Nullable public WorkspaceComments getWorkspaceComments(@Nonnull Workspace workspace) { String input = CommentKey.workspaceInput(workspace); PersistWorkspaceComments persistComments = persistMap.get(input); if (persistComments == null) return null; // No persist model, so there are no comments. // Wrap the persist model with a delegating model for listener support. return delegatingMap.computeIfAbsent(input, i -> newDelegatingWorkspaceComments(workspace, persistComments)); } /** * @return Comments container for the current workspace. * If there is no current workspace, then {@code null}. */ @Nullable public WorkspaceComments getCurrentWorkspaceComments() { if (!workspaceManager.hasCurrentWorkspace()) return null; return getOrCreateWorkspaceComments(workspaceManager.getCurrent()); } /** * @param workspace * Workspace to remove comments of. * * @return {@code true} if a workspace was found and removed. * {@code false} if no comments existed for the workspace. */ public boolean removeWorkspaceComments(@Nonnull Workspace workspace) { String input = CommentKey.workspaceInput(workspace); return persistMap.remove(input) != null || delegatingMap.remove(input) != null; } /** * @param listener * Listener to add. */ public void addCommentListener(@Nonnull CommentUpdateListener listener) { PrioritySortable.add(commentUpdateListeners, listener); } /** * @param listener * Listener to remove. */ public void removeCommentListener(@Nonnull CommentUpdateListener listener) { commentUpdateListeners.remove(listener); } /** * @param listener * Listener to add. */ public void addCommentContainerListener(@Nonnull CommentContainerListener listener) { PrioritySortable.add(commentContainerListeners, listener); } /** * @param listener * Listener to remove. */ public void removeCommentContainerListener(@Nonnull CommentContainerListener listener) { commentContainerListeners.remove(listener); } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public CommentManagerConfig getServiceConfig() { return config; } @Nonnull private Path getCommentsDirectory() { return directoriesConfig.getBaseDirectory().resolve("comments"); } @Nonnull private DelegatingWorkspaceComments newDelegatingWorkspaceComments(@Nonnull Workspace workspace, @Nonnull PersistWorkspaceComments persistComments) { DelegatingWorkspaceComments delegatingComments = new DelegatingWorkspaceComments(this, persistComments); // Initialize delegate class comment models for entries in the persist model. for (String classKey : persistComments.classKeys()) { ClassPathNode classPath = workspace.findClass(classKey); if (classPath != null) delegatingComments.getClassComments(classPath); } return delegatingComments; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/comment/CommentManagerConfig.java ================================================ package software.coley.recaf.services.comment; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.observables.ObservableBoolean; import software.coley.observables.ObservableInteger; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.BasicConfigValue; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; import software.coley.recaf.services.decompile.Decompiler; /** * Config for {@link CommentManager}. * * @author Matt Coley */ @ApplicationScoped public class CommentManagerConfig extends BasicConfigContainer implements ServiceConfig { private final ObservableBoolean enableCommentDisplay = new ObservableBoolean(true); private final ObservableInteger wordWrappingLimit = new ObservableInteger(100); @Inject public CommentManagerConfig() { super(ConfigGroups.SERVICE_ANALYSIS, CommentManager.SERVICE_ID + CONFIG_SUFFIX); // Add values addValue(new BasicConfigValue<>("enable-display", boolean.class, enableCommentDisplay)); addValue(new BasicConfigValue<>("word-wrapping-limit", int.class, wordWrappingLimit)); } /** * @return {@code true} when comments should be enabled in {@link Decompiler} output. */ @Nonnull public ObservableBoolean getEnableCommentDisplay() { return enableCommentDisplay; } /** * @return Number of characters to allow before line wrapping a comment. */ @Nonnull public ObservableInteger getWordWrappingLimit() { return wordWrappingLimit; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/comment/CommentUpdateListener.java ================================================ package software.coley.recaf.services.comment; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.behavior.PrioritySortable; import software.coley.recaf.path.ClassMemberPathNode; import software.coley.recaf.path.ClassPathNode; /** * Listener for receiving updates when comments are added to classes, fields, and methods. * * @author Matt Coley */ public interface CommentUpdateListener extends PrioritySortable { /** * @param path * Path to class commented. * @param comment * Content of comment for the class. Can be {@code null} to denote removal of a comment. */ default void onClassCommentUpdated(@Nonnull ClassPathNode path, @Nullable String comment) {} /** * @param path * Path to field commented. * @param comment * Content of comment for the field. Can be {@code null} to denote removal of a comment. */ default void onFieldCommentUpdated(@Nonnull ClassMemberPathNode path, @Nullable String comment) {} /** * @param path * Path to method commented. * @param comment * Content of comment for the method. Can be {@code null} to denote removal of a comment. */ default void onMethodCommentUpdated(@Nonnull ClassMemberPathNode path, @Nullable String comment) {} } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/comment/DelegatingClassComments.java ================================================ package software.coley.recaf.services.comment; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.path.ClassPathNode; import java.time.Instant; /** * Delegating class comment container implementation. * * @author Matt Coley */ public class DelegatingClassComments implements ClassComments { private final CommentUpdateListener listenerCallback; private final ClassPathNode path; private final ClassComments delegate; /** * New delegating comments container, which passes data to the persistence container, * and invokes {@link CommentUpdateListener} methods when comment data is updated. * * @param path * Path to the class this container is for. * @param listenerCallback * The listener that delegates to other listeners registered in the {@link CommentManager}. * @param delegate * The {@link ClassComments} implementation we actually want to store data in. */ public DelegatingClassComments(@Nonnull ClassPathNode path, @Nonnull CommentUpdateListener listenerCallback, @Nonnull ClassComments delegate) { this.listenerCallback = listenerCallback; this.path = path; this.delegate = delegate; } /** * @return Path to the class this container is for. */ @Nonnull public ClassPathNode getPath() { return path; } @Nonnull @Override public Instant getCreationTime() { return delegate.getCreationTime(); } @Nonnull @Override public Instant getLastUpdatedTime() { return delegate.getLastUpdatedTime(); } @Override public boolean hasComments() { return delegate.hasComments(); } @Nullable @Override public String getClassComment() { return delegate.getClassComment(); } @Override public void setClassComment(@Nullable String comment) { delegate.setClassComment(comment); listenerCallback.onClassCommentUpdated(path, comment); } @Nullable @Override public String getFieldComment(@Nonnull String name, @Nonnull String descriptor) { return delegate.getFieldComment(name, descriptor); } @Nullable @Override public String getMethodComment(@Nonnull String name, @Nonnull String descriptor) { return delegate.getMethodComment(name, descriptor); } @Override public void setFieldComment(@Nonnull String name, @Nonnull String descriptor, @Nullable String comment) { delegate.setFieldComment(name, descriptor, comment); FieldMember field = path.getValue().getDeclaredField(name, descriptor); if (field != null) listenerCallback.onFieldCommentUpdated(path.child(field), comment); } @Override public void setMethodComment(@Nonnull String name, @Nonnull String descriptor, @Nullable String comment) { delegate.setMethodComment(name, descriptor, comment); MethodMember method = path.getValue().getDeclaredMethod(name, descriptor); if (method != null) listenerCallback.onMethodCommentUpdated(path.child(method), comment); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null) return false; return delegate.equals(o); } @Override public int hashCode() { return delegate.hashCode(); } @Override public String toString() { return delegate.toString(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/comment/DelegatingWorkspaceComments.java ================================================ package software.coley.recaf.services.comment; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.collections.Unchecked; import software.coley.recaf.path.ClassPathNode; import java.util.Iterator; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * Delegating workspace comment container implementation. * * @author Matt Coley */ public class DelegatingWorkspaceComments implements WorkspaceComments { private final Map classCommentsMap = new ConcurrentHashMap<>(); private final CommentManager listenerCallback; private final PersistWorkspaceComments delegate; /** * New delegating comments container, which passes data to the persistence container, and manages * the creation of {@link DelegatingClassComments} for backing {@link PersistClassComments} instances. * * @param listenerCallback * The listener that delegates to other listeners registered in the {@link CommentManager}. * @param delegate * The {@link WorkspaceComments} implementation we actually want to store data in. */ public DelegatingWorkspaceComments(@Nonnull CommentManager listenerCallback, @Nonnull PersistWorkspaceComments delegate) { this.listenerCallback = listenerCallback; this.delegate = delegate; } @Nonnull @Override public ClassComments getOrCreateClassComments(@Nonnull ClassPathNode classPath) { // We will be delegating to the persist model when we lazily create our own delegating class comments here. // If the data does not exist upstream in the persist model, it will be made. return classCommentsMap.computeIfAbsent(classPath.getValue().getName(), name -> { DelegatingClassComments newComments = new DelegatingClassComments(classPath, listenerCallback, delegate.getOrCreateClassComments(classPath)); listenerCallback.onClassContainerCreated(classPath, newComments); return newComments; }); } @Nullable @Override public ClassComments getClassComments(@Nonnull ClassPathNode classPath) { // Check if the persist model has comments. If not, we do not need to make a wrapper. ClassComments delegateClassComments = delegate.getClassComments(classPath); if (delegateClassComments == null) return null; // Create a wrapper if one does not exist for the persist class comments model. return classCommentsMap.computeIfAbsent(classPath.getValue().getName(), name -> { DelegatingClassComments newComments = new DelegatingClassComments(classPath, listenerCallback, delegateClassComments); listenerCallback.onClassContainerCreated(classPath, newComments); return newComments; }); } @Nullable @Override public ClassComments deleteClassComments(@Nonnull ClassPathNode classPath) { ClassComments container = delegate.deleteClassComments(classPath); if (container != null) listenerCallback.onClassContainerRemoved(classPath, container); return container; } @Nonnull @Override public Iterator iterator() { return Unchecked.cast(classCommentsMap.values().iterator()); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null) return false; return delegate.equals(o); } @Override public int hashCode() { return delegate.hashCode(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/comment/PersistClassComments.java ================================================ package software.coley.recaf.services.comment; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import java.time.Instant; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; /** * Basic class comment container for persistence. * * @author Matt Coley */ public class PersistClassComments implements ClassComments { private final Map fieldComments = new ConcurrentHashMap<>(); private final Map methodComments = new ConcurrentHashMap<>(); private final Instant creationTime = Instant.now(); private Instant lastUpdatedTime = creationTime; private String classComment; @Nonnull @Override public Instant getCreationTime() { return creationTime; } @Nonnull @Override public Instant getLastUpdatedTime() { return lastUpdatedTime; } @Override public boolean hasComments() { return classComment != null || fieldComments.values().stream().anyMatch(s -> s != null && !s.isBlank()) || methodComments.values().stream().anyMatch(s -> s != null && !s.isBlank()); } @Nullable @Override public String getClassComment() { return classComment; } @Override public void setClassComment(@Nullable String comment) { classComment = comment; lastUpdatedTime = Instant.now(); } @Nullable @Override public String getFieldComment(@Nonnull String name, @Nonnull String descriptor) { return fieldComments.get(name + ' ' + descriptor); } @Nullable @Override public String getMethodComment(@Nonnull String name, @Nonnull String descriptor) { return methodComments.get(name + descriptor); } @Override public void setFieldComment(@Nonnull String name, @Nonnull String descriptor, @Nullable String comment) { String key = name + ' ' + descriptor; if (comment == null) fieldComments.remove(key); else fieldComments.put(key, comment); lastUpdatedTime = Instant.now(); } @Override public void setMethodComment(@Nonnull String name, @Nonnull String descriptor, @Nullable String comment) { String key = name + descriptor; if (comment == null) methodComments.remove(key); else methodComments.put(key, comment); lastUpdatedTime = Instant.now(); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; PersistClassComments that = (PersistClassComments) o; if (!fieldComments.equals(that.fieldComments)) return false; if (!methodComments.equals(that.methodComments)) return false; return Objects.equals(classComment, that.classComment); } @Override public int hashCode() { int result = fieldComments.hashCode(); result = 31 * result + methodComments.hashCode(); result = 31 * result + (classComment != null ? classComment.hashCode() : 0); return result; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/comment/PersistWorkspaceComments.java ================================================ package software.coley.recaf.services.comment; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.collections.Unchecked; import software.coley.recaf.path.ClassPathNode; import java.util.Collection; import java.util.Iterator; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * Basic workspace comment container for persistence. * * @author Matt Coley */ public class PersistWorkspaceComments implements WorkspaceComments { private final Map classCommentsMap = new ConcurrentHashMap<>(); /** * @return Names of classes with comment containers. */ @Nonnull Collection classKeys() { // The class keys are exposed so that the comment manager can copy state over to the delegate models. return classCommentsMap.keySet(); } @Nonnull @Override public ClassComments getOrCreateClassComments(@Nonnull ClassPathNode classPath) { return classCommentsMap.computeIfAbsent(classPath.getValue().getName(), name -> new PersistClassComments()); } @Nullable @Override public ClassComments getClassComments(@Nonnull ClassPathNode classPath) { return classCommentsMap.get(classPath.getValue().getName()); } @Nullable @Override public ClassComments deleteClassComments(@Nonnull ClassPathNode classPath) { return classCommentsMap.remove(classPath.getValue().getName()); } @Nonnull @Override public Iterator iterator() { return Unchecked.cast(classCommentsMap.values().iterator()); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; PersistWorkspaceComments that = (PersistWorkspaceComments) o; return classCommentsMap.equals(that.classCommentsMap); } @Override public int hashCode() { return classCommentsMap.hashCode(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/comment/WorkspaceComments.java ================================================ package software.coley.recaf.services.comment; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.member.ClassMember; import software.coley.recaf.path.ClassMemberPathNode; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.path.PathNode; /** * Outline of a container for commented elements in a workspace. * * @author Matt Coley */ public interface WorkspaceComments extends Iterable { /** * @param classPath * Class path within a workspace. * * @return Comments container for the class, creating a new container if none exist. */ @Nonnull ClassComments getOrCreateClassComments(@Nonnull ClassPathNode classPath); /** * @param classPath * Class path within a workspace. * * @return Comments container for the class, if comments exist for the class. Otherwise {@code null}. */ @Nullable ClassComments getClassComments(@Nonnull ClassPathNode classPath); /** * @param classPath * Class path within a workspace. * * @return The removed comments container for the class, or {@code null} if no comments previously existed. */ @Nullable ClassComments deleteClassComments(@Nonnull ClassPathNode classPath); /** * @param path * Class or member path within a workspace. * * @return Class or member comment, if any is associated with the path. */ @Nullable default String getComment(@Nonnull PathNode path) { if (path instanceof ClassPathNode classPath) return getClassComment(classPath); else if (path instanceof ClassMemberPathNode memberPath) return getMemberComment(memberPath); return null; } /** * @param classPath * Class path within a workspace. * * @return Class comment, if any is associated with the path. */ @Nullable default String getClassComment(@Nonnull ClassPathNode classPath) { ClassComments classComments = getClassComments(classPath); if (classComments == null) return null; return classComments.getClassComment(); } /** * @param memberPath * Member path within a workspace. * * @return Member comment, if any is associated with the path. */ @Nullable default String getMemberComment(@Nonnull ClassMemberPathNode memberPath) { ClassPathNode classPath = memberPath.getParent(); if (classPath == null) return null; ClassComments classComments = getClassComments(classPath); if (classComments == null) return null; ClassMember member = memberPath.getValue(); if (member.isField()) return classComments.getFieldComment(member.getName(), member.getDescriptor()); else return classComments.getMethodComment(member.getName(), member.getDescriptor()); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/compile/CompileMap.java ================================================ package software.coley.recaf.services.compile; import jakarta.annotation.Nonnull; import org.slf4j.Logger; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.util.JavaDowngraderUtil; import software.coley.recaf.util.JavaVersion; import xyz.wagyourtail.jvmdg.ClassDowngrader; import java.io.IOException; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.TreeMap; import java.util.concurrent.atomic.AtomicReference; /** * Wrapper of a string-to-bytecode map with additional utility methods. * * @author Matt Coley */ public class CompileMap extends TreeMap { private static final Logger logger = Logging.get(CompileMap.class); /** * @param map * Map to copy. */ public CompileMap(@Nonnull Map map) { super(map); } /** * @return {@code true} when multiple classes are in the map. */ public boolean hasMultipleClasses() { return size() > 1; } /** * @return {@code true} when multiple classes are in the map, * and one of them is an inner class of one of the others. */ public boolean hasInnerClasses() { if (hasMultipleClasses()) { for (String name : keySet()) { // Name contains the inner class separator "$" and the outer-class is also in the map if (name.contains("$") && containsKey(name.substring(0, name.lastIndexOf("$")))) { return true; } } } return false; } /** * Down sample all classes in the map to the target version. * * @param targetJavaVersion * Target version to downsample to. To target 8, simply pass {@code 8} (). */ public void downsample(int targetJavaVersion) { try { JavaDowngraderUtil.downgrade(targetJavaVersion, new HashMap<>(this), this::put); } catch (IOException ex) { logger.error("Failed down sampling to version {}", targetJavaVersion, ex); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/compile/CompilerDiagnostic.java ================================================ package software.coley.recaf.services.compile; import jakarta.annotation.Nonnull; /** * Simple compiler feedback wrapper. * * @param line * Line the message applies to. * @param column * Column the message applies to within the line. * @param length * Length beyond the column position where the message applies to. * @param message * Message detailing the problem. * @param level * Diagnostic problem level. * * @author Matt Coley */ public record CompilerDiagnostic(int line, int column, int length, @Nonnull String message, @Nonnull Level level) { /** * @param line * New line number. * * @return Copy of diagnostic, with changed line number. */ @Nonnull public CompilerDiagnostic withLine(int line) { return new CompilerDiagnostic(line, column, length, message, level); } @Override public String toString() { return level.name() + " on line " + line + ": " + message; } /** * Diagnostic level. */ public enum Level { ERROR, WARNING, INFO } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/compile/CompilerResult.java ================================================ package software.coley.recaf.services.compile; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import java.util.Collections; import java.util.List; /** * Compiler results wrapper. * * @author Matt Coley */ public class CompilerResult { private final CompileMap compilations; private final List diagnostics; private final Throwable exception; /** * @param exception * Error thrown when attempting to compile. */ public CompilerResult(@Nonnull Throwable exception) { this(new CompileMap(Collections.emptyMap()), Collections.emptyList(), exception); } /** * @param compileMap * Compilation results. * @param diagnostics * Compilation problem diagnostics. */ public CompilerResult(@Nonnull CompileMap compileMap, @Nonnull List diagnostics) { this(compileMap, diagnostics, null); } private CompilerResult(@Nonnull CompileMap compilations, @Nonnull List diagnostics, @Nullable Throwable exception) { this.compilations = compilations; this.exception = exception; this.diagnostics = diagnostics; } /** * @return {@code true} when there are compilations, and no errors thrown. */ public boolean wasSuccess() { return compilations != null && !compilations.isEmpty() && exception == null && diagnostics.stream().noneMatch(d -> d.level() == CompilerDiagnostic.Level.ERROR); } /** * @return Compilation results. * Empty when there are is an {@link #getException()}. */ @Nonnull public CompileMap getCompilations() { return compilations; } /** * @return Compilation problem diagnostics. * Empty when {@link #wasSuccess()}. */ @Nonnull public List getDiagnostics() { return diagnostics; } /** * @return Error thrown when attempting to compile. * {@code null} when compilation was a success. */ @Nullable public Throwable getException() { return exception; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/compile/ForwardingListener.java ================================================ package software.coley.recaf.services.compile; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import javax.tools.Diagnostic; import javax.tools.JavaFileObject; /** * Diagnostic listener that forwards reports to a delegate listener. * * @author Matt Coley */ public class ForwardingListener implements JavacListener { private final JavacListener delegate; ForwardingListener(@Nullable JavacListener delegate) { this.delegate = delegate; } @Override public void report(@Nonnull Diagnostic diagnostic) { if (delegate != null) delegate.report(diagnostic); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/compile/JavacArguments.java ================================================ package software.coley.recaf.services.compile; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.workspace.model.Workspace; import java.util.Objects; /** * Arguments to pass to {@link JavacCompiler#compile(JavacArguments, Workspace, JavacListener)}. * * @author Matt Coley * @see JavacArgumentsBuilder */ public class JavacArguments { // Primary inputs private final String className; private final String classSource; // Options private final String classPath; private final int versionTarget; private final int downsampleTarget; private final boolean debugVariables; private final boolean debugLineNumbers; private final boolean debugSourceName; /** * @param className * Internal name of the class being compiled. * @param classSource * Source of the class. * @param classPath * Classpath to use with compiler. * @param versionTarget * Java version to target. * @param downsampleTarget * Java version to target via down sampling. Negative to disable downs sampling. * @param debugVariables * Debug flag to include variable info. * @param debugLineNumbers * Debug flag to include line number info. * @param debugSourceName * Debug flag to include source file name. */ public JavacArguments(@Nonnull String className, @Nonnull String classSource, @Nullable String classPath, int versionTarget, int downsampleTarget, boolean debugVariables, boolean debugLineNumbers, boolean debugSourceName) { this.className = className; this.classSource = classSource; this.classPath = classPath; this.versionTarget = versionTarget; this.downsampleTarget = downsampleTarget; this.debugVariables = debugVariables; this.debugLineNumbers = debugLineNumbers; this.debugSourceName = debugSourceName; } /** * @return String representation of debug flags. */ @Nonnull public String createDebugValue() { StringBuilder s = new StringBuilder(); if (debugVariables) s.append("vars,"); if (debugLineNumbers) s.append("lines,"); if (debugSourceName) s.append("source"); // edge case if (s.isEmpty()) return "-g:none"; // Substring off dangling comma String value = s.toString(); if (value.endsWith(",")) value = s.substring(0, value.length() - 1); return "-g:" + value; } /** * @return Internal name of the class being compiled. */ @Nonnull public String getClassName() { return className; } /** * @return Source of the class. */ @Nonnull public String getClassSource() { return classSource; } /** * @return Classpath to use with compiler. */ @Nullable public String getClassPath() { return classPath; } /** * @return Java version to target. */ public int getVersionTarget() { return versionTarget; } /** * @return Java version to target via down sampling. Negative to disable downs sampling. */ public int getDownsampleTarget() { return Math.min(downsampleTarget, JavacCompiler.MIN_DOWNSAMPLE_VER); } /** * @return Debug flag to include variable info. */ public boolean isDebugVariables() { return debugVariables; } /** * @return Debug flag to include line number info. */ public boolean isDebugLineNumbers() { return debugLineNumbers; } /** * @return Debug flag to include source file name. */ public boolean isDebugSourceName() { return debugSourceName; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; JavacArguments other = (JavacArguments) o; if (versionTarget != other.versionTarget) return false; if (debugVariables != other.debugVariables) return false; if (debugLineNumbers != other.debugLineNumbers) return false; if (debugSourceName != other.debugSourceName) return false; if (!className.equals(other.className)) return false; if (!classSource.equals(other.classSource)) return false; return Objects.equals(classPath, other.classPath); } @Override public int hashCode() { int result = className.hashCode(); result = 31 * result + classSource.hashCode(); result = 31 * result + (classPath != null ? classPath.hashCode() : 0); result = 31 * result + versionTarget; result = 31 * result + (debugVariables ? 1 : 0); result = 31 * result + (debugLineNumbers ? 1 : 0); result = 31 * result + (debugSourceName ? 1 : 0); return result; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/compile/JavacArgumentsBuilder.java ================================================ package software.coley.recaf.services.compile; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.util.JavaVersion; /** * Builder for {@link JavacArguments}. * * @author Matt Coley */ public final class JavacArgumentsBuilder { private String className; private String classSource; private String classPath = System.getProperty("java.class.path"); private int versionTarget = JavaVersion.get(); private int downsampleTarget = -1; private boolean debugVariables = true; private boolean debugLineNumbers = true; private boolean debugSourceName = true; /** * @param className * Internal name of the class being compiled. * * @return Builder. */ @Nonnull public JavacArgumentsBuilder withClassName(@Nonnull String className) { this.className = className; return this; } /** * @param classSource * Source of the class. * * @return Builder. */ @Nonnull public JavacArgumentsBuilder withClassSource(@Nonnull String classSource) { this.classSource = classSource; return this; } /** * @param classPath * Classpath to use with compiler. * * @return Builder. */ @Nonnull public JavacArgumentsBuilder withClassPath(@Nullable String classPath) { this.classPath = classPath; return this; } /** * @param downsampleTarget * Java version to target via down sampling. Negative to disable downs sampling. * See: {@link JavacCompiler#MIN_DOWNSAMPLE_VER} for lowest supported target. * * @return Builder. */ @Nonnull public JavacArgumentsBuilder withDownsampleTarget(int downsampleTarget) { this.downsampleTarget = downsampleTarget; return this; } /** * @param versionTarget * Java version to target. * * @return Builder. */ @Nonnull public JavacArgumentsBuilder withVersionTarget(int versionTarget) { this.versionTarget = versionTarget; return this; } /** * @param debugVariables * Debug flag to include variable info. * * @return Builder. */ @Nonnull public JavacArgumentsBuilder withDebugVariables(boolean debugVariables) { this.debugVariables = debugVariables; return this; } /** * @param debugLineNumbers * Debug flag to include line number info. * * @return Builder. */ @Nonnull public JavacArgumentsBuilder withDebugLineNumbers(boolean debugLineNumbers) { this.debugLineNumbers = debugLineNumbers; return this; } /** * @param debugSourceName * Debug flag to include source file name. * * @return Builder. */ @Nonnull public JavacArgumentsBuilder withDebugSourceName(boolean debugSourceName) { this.debugSourceName = debugSourceName; return this; } /** * @return Arguments instance. */ @Nonnull public JavacArguments build() { if (className == null) throw new IllegalArgumentException("Class name must not be null"); if (classSource == null) throw new IllegalArgumentException("Class source must not be null"); return new JavacArguments(className, classSource, classPath, versionTarget, downsampleTarget, debugVariables, debugLineNumbers, debugSourceName); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/compile/JavacCompiler.java ================================================ package software.coley.recaf.services.compile; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.collections.Lists; import software.coley.recaf.analytics.logging.DebuggingLogger; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.Service; import software.coley.recaf.services.phantom.GeneratedPhantomWorkspaceResource; import software.coley.recaf.services.phantom.PhantomGenerationException; import software.coley.recaf.services.phantom.PhantomGenerator; import software.coley.recaf.util.ReflectUtil; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import javax.tools.Diagnostic; import javax.tools.JavaCompiler; import javax.tools.JavaFileManager; import javax.tools.JavaFileObject; import javax.tools.ToolProvider; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.stream.Collectors; import static java.nio.charset.StandardCharsets.UTF_8; /** * Wrapper for {@link JavaCompiler}. *
* Worth note, the minimum supported version is declared in {@code com.sun.tools.javac.jvm.Target} but is marked * as an unstable API subject to change without notice. As of Java 17, the minimum target version is Java 7. * * @author Matt Coley */ @ApplicationScoped public class JavacCompiler implements Service { public static final String SERVICE_ID = "java-compiler"; public static final int MIN_DOWNSAMPLE_VER = 8; private static final DebuggingLogger logger = Logging.get(JavacCompiler.class); private static final JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); private static int minTargetVersion = 7; private final PhantomGenerator phantomGenerator; private final JavacCompilerConfig config; @Inject public JavacCompiler(@Nonnull PhantomGenerator phantomGenerator, @Nonnull JavacCompilerConfig config) { this.phantomGenerator = phantomGenerator; this.config = config; } /** * @param arguments * Wrapper of all arguments. * @param workspace * Optional workspace to include for additional classpath support. * @param listener * Optional listener to handle feedback with, * mirroring what is reported by {@link CompilerResult#getDiagnostics()} * * @return Compilation result wrapper. */ @Nonnull public CompilerResult compile(@Nonnull JavacArguments arguments, @Nullable Workspace workspace, @Nullable JavacListener listener) { return compile(arguments, workspace, null, listener); } /** * @param arguments * Wrapper of all arguments. * @param workspace * Optional workspace to include for additional classpath support. * @param supplementaryResources * Optional resources to further extend the compilation classpath with. * @param listener * Optional listener to handle feedback with, * mirroring what is reported by {@link CompilerResult#getDiagnostics()} * * @return Compilation result wrapper. */ @Nonnull public CompilerResult compile(@Nonnull JavacArguments arguments, @Nullable Workspace workspace, @Nullable List supplementaryResources, @Nullable JavacListener listener) { if (compiler == null) return new CompilerResult(new IllegalStateException("Cannot load 'javac' compiler.")); String className = arguments.getClassName(); // Class input map VirtualUnitMap unitMap = new VirtualUnitMap(); unitMap.addSource(className, arguments.getClassSource()); // Create a file manager to track files in-memory rather than on-disk List virtualClassPath = workspace == null ? Collections.emptyList() : workspace.getAllResources(true); if (supplementaryResources != null) virtualClassPath = Lists.combine(virtualClassPath, supplementaryResources); // Generate phantom classes if the workspace does not already have phantoms in it. if (workspace != null && config.getGeneratePhantoms().getValue() && workspace.getSupportingResources().stream().noneMatch(resource -> resource instanceof GeneratedPhantomWorkspaceResource)) { // Only scan the target class and any of its inner classes for content to fill in. List classesToScan = workspace.findJvmClasses(c -> c.getName().equals(className) || c.isInnerClassOf(className)).stream() .map(p -> p.getValue().asJvmClass()) .collect(Collectors.toList()); if (!classesToScan.isEmpty()) { try { WorkspaceResource phantomResource = phantomGenerator.createPhantomsForClasses(workspace, classesToScan); int generatedCount = phantomResource.getJvmClassBundle().size(); if (generatedCount > 0) logger.debug("Generated {} phantoms for pre-compile", generatedCount); virtualClassPath = Lists.add(virtualClassPath, phantomResource); } catch (PhantomGenerationException ex) { logger.warn("Failed to generate phantoms for compilation against '{}'", className, ex); } } } List diagnostics = new ArrayList<>(); JavacListener listenerWrapper = createRecordingListener(listener, diagnostics); JavaFileManager fmFallback = compiler.getStandardFileManager(listenerWrapper, Locale.getDefault(), UTF_8); JavaFileManager fm = new VirtualFileManager(unitMap, virtualClassPath, fmFallback); // Populate arguments List args = new ArrayList<>(); // Classpath String cp = arguments.getClassPath(); if (cp != null) { args.add("-classpath"); args.add(cp); logger.debugging(l -> l.info("Compiler classpath: {}", cp)); } // Target version int target = arguments.getVersionTarget(); args.add("--release"); args.add(Integer.toString(target)); logger.debugging(l -> l.info("Compiler target: {}", target)); // Debug info String debugArg = arguments.createDebugValue(); args.add(debugArg); logger.debugging(l -> l.info("Compiler debug: {}", debugArg)); // Invoke compiler try { JavaCompiler.CompilationTask task = compiler.getTask(null, fm, listenerWrapper, args, null, unitMap.getFiles()); if (task.call()) { logger.debugging(l -> l.info("Compilation of '{}' finished", className)); } else { logger.debugging(l -> l.error("Compilation of '{}' failed", className)); } CompileMap compilations = unitMap.getCompilations(); int downsampleTarget = arguments.getDownsampleTarget(); if (downsampleTarget >= MIN_DOWNSAMPLE_VER) compilations.downsample(downsampleTarget); else if (downsampleTarget >= 0) logger.warn("Cannot downsample beyond Java {}", JavacCompiler.MIN_DOWNSAMPLE_VER); return new CompilerResult(compilations, diagnostics); } catch (RuntimeException ex) { logger.debugging(l -> l.error("Compilation of '{}' crashed", className, ex)); return new CompilerResult(ex); } } /** * @return {@code true} when the compiler can be invoked. */ public static boolean isAvailable() { return compiler != null; } /** * @return Minimum target version supported by the compiler. */ public static int getMinTargetVersion() { return minTargetVersion; } /** * @param listener * Optional listener to wrap. * @param diagnostics * List to add diagnostics to. * * @return Listener to encompass recording behavior and the user defined listener. */ private JavacListener createRecordingListener(@Nullable JavacListener listener, @Nonnull List diagnostics) { return new ForwardingListener(listener) { @Override public void report(@Nonnull Diagnostic diagnostic) { // Pass to user defined listener super.report(diagnostic); // Record the diagnostic to our output if (diagnostic.getKind() == Diagnostic.Kind.ERROR) { diagnostics.add(new CompilerDiagnostic( (int) diagnostic.getLineNumber(), (int) diagnostic.getColumnNumber(), (int) diagnostic.getEndPosition() - (int) diagnostic.getPosition(), diagnostic.getMessage(Locale.getDefault()), mapKind(diagnostic.getKind()) )); } } private CompilerDiagnostic.Level mapKind(Diagnostic.Kind kind) { switch (kind) { case ERROR: return CompilerDiagnostic.Level.ERROR; case WARNING: case MANDATORY_WARNING: return CompilerDiagnostic.Level.WARNING; case NOTE: case OTHER: default: return CompilerDiagnostic.Level.INFO; } } }; } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public JavacCompilerConfig getServiceConfig() { return config; } static { // Lookup oldest supported version try { MethodHandles.Lookup lookup = ReflectUtil.lookup(); Class target = Class.forName("com.sun.tools.javac.jvm.Target"); MethodHandle min = lookup.findStaticGetter(target, "MIN", target); Object minTarget = min.invoke(); Field majorVersion = minTarget.getClass().getDeclaredField("majorVersion"); majorVersion.setAccessible(true); minTargetVersion = majorVersion.getInt(minTarget) - JvmClassInfo.BASE_VERSION; } catch (Throwable ignored) { // Oh well... } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/compile/JavacCompilerConfig.java ================================================ package software.coley.recaf.services.compile; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.observables.ObservableBoolean; import software.coley.observables.ObservableInteger; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.BasicConfigValue; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; import software.coley.recaf.workspace.model.Workspace; import java.util.List; /** * Config for {@link JavacCompiler}. *
* Not to be confused with {@link JavacArguments individual arguments} to be passed when invoking the compiler. * * @author Matt Coley */ @ApplicationScoped public class JavacCompilerConfig extends BasicConfigContainer implements ServiceConfig { private final ObservableBoolean generatePhantoms = new ObservableBoolean(true); private final ObservableBoolean defaultEmitDebug = new ObservableBoolean(true); private final ObservableInteger defaultTargetVersion = new ObservableInteger(-1); private final ObservableInteger defaultDownsampleTargetVersion = new ObservableInteger(-1); @Inject public JavacCompilerConfig() { super(ConfigGroups.SERVICE_COMPILE, JavacCompiler.SERVICE_ID + CONFIG_SUFFIX); addValue(new BasicConfigValue<>("generate-phantoms", boolean.class, generatePhantoms)); addValue(new BasicConfigValue<>("default-emit-debug", boolean.class, defaultEmitDebug)); addValue(new BasicConfigValue<>("default-compile-target-version", int.class, defaultTargetVersion)); addValue(new BasicConfigValue<>("default-downsample-target-version", int.class, defaultDownsampleTargetVersion)); } /** * Not enforced internally by {@link JavacCompiler}. * Callers should check this value and ensure to call * {@link JavacCompiler#compile(JavacArguments, Workspace, List, JavacListener)} with the list populated. * * @return {@code true} to enable phantom generation when calling {@code javac}. */ @Nonnull public ObservableBoolean getGeneratePhantoms() { return generatePhantoms; } /** * Not enforced internally by {@link JavacCompiler}. * Callers should check this value and ensure to call {@link JavacArgumentsBuilder#withDebugVariables(boolean)} * and other debug methods. * * @return {@code true} to enable debug info by default. */ @Nonnull public ObservableBoolean getDefaultEmitDebug() { return defaultEmitDebug; } /** * Not enforced internally by {@link JavacCompiler}. * Callers should check this value and ensure to call {@link JavacArgumentsBuilder#withVersionTarget(int)}. * * @return Negative to match the input version of the class, otherwise target version * (In class file version format) */ @Nonnull public ObservableInteger getDefaultTargetVersion() { return defaultTargetVersion; } /** * Not enforced internally by {@link JavacCompiler}. * Callers should check this value and ensure to call {@link JavacArgumentsBuilder#withDownsampleTarget(int)}. * * @return Negative to disable down sampling, otherwise target version to downsample compiled code to * (In class file version format) */ @Nonnull public ObservableInteger getDefaultDownsampleTargetVersion() { return defaultDownsampleTargetVersion; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/compile/JavacListener.java ================================================ package software.coley.recaf.services.compile; import javax.tools.DiagnosticListener; import javax.tools.JavaFileObject; /** * Diagnostic list wrapper. * * @author Matt Coley */ public interface JavacListener extends DiagnosticListener {} ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/compile/ResourceVirtualJavaFileObject.java ================================================ package software.coley.recaf.services.compile; import jakarta.annotation.Nonnull; import javax.tools.SimpleJavaFileObject; import java.io.ByteArrayInputStream; import java.io.InputStream; import java.net.URI; /** * Java file extension that exposes workspace resource for classpath. * * @author xDark */ public class ResourceVirtualJavaFileObject extends SimpleJavaFileObject { private final String resourceName; private final byte[] content; /** * @param resourceName * Name of the resource. * @param content * Class source content. * @param resourceKind * Kind of the resource. */ public ResourceVirtualJavaFileObject(String resourceName, byte[] content, Kind resourceKind) { super(URI.create("memory://" + resourceName + resourceKind.extension), resourceKind); this.resourceName = resourceName; this.content = content; } /** * @return Resource name. */ @Nonnull public String getResourceName() { return resourceName; } @Override public InputStream openInputStream() { return new ByteArrayInputStream(content); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/compile/VirtualFileManager.java ================================================ package software.coley.recaf.services.compile; import jakarta.annotation.Nonnull; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import javax.tools.FileObject; import javax.tools.ForwardingJavaFileManager; import javax.tools.JavaFileManager; import javax.tools.JavaFileObject; import javax.tools.StandardLocation; import java.io.IOException; import java.util.Iterator; import java.util.List; import java.util.Set; import java.util.function.Predicate; /** * File manager extension for handling updates to java file object's output stream. * Additionally, registers inner classes as new files. * * @author Matt Coley */ public class VirtualFileManager extends ForwardingJavaFileManager { private final VirtualUnitMap unitMap; private final List virtualClasspath; /** * @param unitMap * Class input map. * @param virtualClasspath * In-memory classpath. * @param fallback * Fallback manager. */ public VirtualFileManager(@Nonnull VirtualUnitMap unitMap, @Nonnull List virtualClasspath, @Nonnull JavaFileManager fallback) { super(fallback); this.virtualClasspath = virtualClasspath; this.unitMap = unitMap; } @Override public Iterable list(@Nonnull Location location, @Nonnull String packageName, @Nonnull Set kinds, boolean recurse) throws IOException { Iterable list = super.list(location, packageName, kinds, recurse); if (StandardLocation.CLASS_PATH.equals(location) && kinds.contains(JavaFileObject.Kind.CLASS)) { String formatted = packageName.isEmpty() ? "" : packageName.replace('.', '/') + '/'; Predicate check; if (recurse) { check = name -> name.startsWith(formatted); } else { check = name -> name.startsWith(formatted) && name.indexOf('/', formatted.length()) == -1; } return () -> new ClassPathIterator(list.iterator(), virtualClasspath.stream() .flatMap(resource -> resource.jvmClassBundleStreamRecursive().flatMap(b -> b.entrySet().stream())) .filter(entry -> check.test(entry.getKey())) .map(entry -> new ResourceVirtualJavaFileObject(entry.getKey(), entry.getValue().getBytecode(), JavaFileObject.Kind.CLASS)) .iterator()); } return list; } @Override public String inferBinaryName(@Nonnull Location location, @Nonnull JavaFileObject file) { if (file instanceof ResourceVirtualJavaFileObject virtualObject && file.getKind() == JavaFileObject.Kind.CLASS) { return virtualObject.getResourceName().replace('/', '.'); } return super.inferBinaryName(location, file); } @Override public JavaFileObject getJavaFileForOutput(@Nonnull JavaFileManager.Location location, @Nonnull String name, @Nonnull JavaFileObject.Kind kind, FileObject sibling) { // Name should be like "com.example.MyClass$MyInner" String internal = name.replace('.', '/'); VirtualJavaFileObject obj = unitMap.getFile(internal); // Unknown class, assumed to be an inner class. // Add it to the unit map, so it can be fetched. if (obj == null) { obj = new VirtualJavaFileObject(internal, null); unitMap.addFile(internal, obj); } return obj; } private static final class ClassPathIterator implements Iterator { private final Iterator first, second; ClassPathIterator(@Nonnull Iterator first, @Nonnull Iterator second) { this.first = first; this.second = second; } @Override public boolean hasNext() { return first.hasNext() || second.hasNext(); } @Override public JavaFileObject next() { if (first.hasNext()) return first.next(); return second.next(); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/compile/VirtualJavaFileObject.java ================================================ package software.coley.recaf.services.compile; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import javax.tools.SimpleJavaFileObject; import java.io.ByteArrayOutputStream; import java.io.OutputStream; import java.net.URI; /** * Java file extension that keeps track of the compiled bytecode. * * @author Matt Coley */ public class VirtualJavaFileObject extends SimpleJavaFileObject { private final ByteArrayOutputStream baos = new ByteArrayOutputStream(); private final String content; /** * @param className * Name of class. * @param content * Content of source file to compile. */ public VirtualJavaFileObject(@Nonnull String className, @Nullable String content) { super(URI.create("string:///" + className.replace('.', '/') + Kind.SOURCE.extension), Kind.SOURCE); this.content = content; } /** * @return {@code true} when {@link #getBytecode()} has content. */ public boolean hasOutput() { return baos.size() > 0; } /** * @return Compiled bytecode of class. */ @Nonnull public byte[] getBytecode() { return baos.toByteArray(); } /** * @return Class source code. */ @Nonnull public String getSource() { return content; } @Override public final OutputStream openOutputStream() { return baos; } @Override public CharSequence getCharContent(boolean ignoreEncodingErrors) { return content; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/compile/VirtualUnitMap.java ================================================ package software.coley.recaf.services.compile; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import java.util.Collection; import java.util.HashMap; import java.util.Map; import java.util.stream.Collectors; /** * {@link javax.tools.JavaFileObject} map wrapper for managing class inputs for the compiler. * * @author Matt Coley */ public class VirtualUnitMap { private final Map unitMap = new HashMap<>(); /** * Add class to compilation process. * * @param className * Name of class to compile. * @param content * Source code of class. */ public void addSource(@Nonnull String className, @Nonnull String content) { addFile(className, new VirtualJavaFileObject(className, content)); } /** * Add class to compilation process. * * @param className * Name of class to compile. * @param fileObject * File object for source code of class. */ public void addFile(@Nonnull String className, @Nonnull VirtualJavaFileObject fileObject) { unitMap.put(className, fileObject); } /** * @param className * Name of class. * * @return File object for source code of class. */ @Nullable public VirtualJavaFileObject getFile(@Nonnull String className) { return unitMap.get(className); } /** * @return Collection of file objects for input classes. */ @Nonnull public Collection getFiles() { return unitMap.values(); } /** * @return Map of class names to bytecode. * Items that failed to compile will not have entries. */ @Nonnull public CompileMap getCompilations() { Map map = unitMap.entrySet().stream() .filter(e -> e.getValue().hasOutput()) .collect(Collectors.toMap(Map.Entry::getKey, v -> v.getValue().getBytecode())); return new CompileMap(map); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/compile/stub/ClassStubGenerator.java ================================================ package software.coley.recaf.services.compile.stub; import dev.xdark.blw.type.ArrayType; import dev.xdark.blw.type.ClassType; import dev.xdark.blw.type.InstanceType; import dev.xdark.blw.type.MethodType; import dev.xdark.blw.type.ObjectType; import dev.xdark.blw.type.PrimitiveType; import dev.xdark.blw.type.Type; import dev.xdark.blw.type.Types; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.objectweb.asm.ClassReader; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.InnerClassInfo; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.services.assembler.ExpressionCompileException; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.services.inheritance.InheritanceVertex; import software.coley.recaf.util.AccessFlag; import software.coley.recaf.util.Keywords; import software.coley.recaf.util.StringUtil; import software.coley.recaf.util.visitors.SkippingClassVisitor; import software.coley.recaf.workspace.model.Workspace; import java.lang.reflect.Modifier; import java.util.Comparator; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; /** * Base stub generator for classes. * * @author Matt Coley */ public abstract class ClassStubGenerator { protected final Workspace workspace; protected final InheritanceGraph inheritanceGraph; protected final int classAccess; protected final String className; protected final String superName; protected final List implementing; protected final List fields; protected final List methods; protected final List innerClasses; /** * @param workspace * Workspace to pull class information from. * @param inheritanceGraph * Inheritance graph of the workspace. * @param classAccess * Host class access modifiers. * @param className * Host class name. * @param superName * Host class super name. * @param implementing * Host class interfaces implemented. * @param fields * Host class declared fields. * @param methods * Host class declared methods. * @param innerClasses * Host class declared inner classes. */ public ClassStubGenerator(@Nonnull Workspace workspace, @Nonnull InheritanceGraph inheritanceGraph, int classAccess, @Nonnull String className, @Nullable String superName, @Nonnull List implementing, @Nonnull List fields, @Nonnull List methods, @Nonnull List innerClasses) { this.workspace = workspace; this.inheritanceGraph = inheritanceGraph; this.classAccess = classAccess; this.className = isSafeInternalClassName(className) ? className : "obfuscated_class"; this.superName = isSafeReferencableName(superName) ? superName : null; this.implementing = implementing.stream() .filter(this::isSafeReferencableName) .toList(); this.fields = fields; this.methods = methods; this.innerClasses = innerClasses; } /** * @return Generated stub for the target class. * * @throws ExpressionCompileException * When the class could not be fully stubbed out. */ public abstract String generate() throws ExpressionCompileException; /** * Appends a package declaration if the {@link #className} is not in the default package. * * @param code * Class code to append package declaration to. */ protected void appendPackage(@Nonnull StringBuilder code) { if (className.indexOf('/') > 0) { String packageName = className.replace('/', '.').substring(0, className.lastIndexOf('/')); code.append("package ").append(packageName).append(";\n"); } } /** * Appends the class's access modifiers, type (class, interface, enum), name, extended type, and any implemented interfaces. * * @param code * Class code to append the class type structure to. */ protected void appendClassStructure(@Nonnull StringBuilder code) { // Class structure InheritanceVertex classVertex = inheritanceGraph.getVertex(className); if (classVertex != null && classVertex.getParents().stream().anyMatch(this::isSealedType)) code.append("non-sealed "); code.append(AccessFlag.isEnum(classAccess) ? "enum " : getLocalModifier() + " class ").append(getLocalName()); if (superName != null && !superName.equals("java/lang/Object") && !superName.equals("java/lang/Enum")) code.append(" extends ").append(superName.replace('/', '.')); if (implementing != null && !implementing.isEmpty()) code.append(" implements ").append(implementing.stream().map(s -> s.replace('/', '.')).collect(Collectors.joining(", "))).append(' '); code.append("{\n"); } /** * Appends enum constants defined in {@link #fields} to the class. * Must be called before {@link #appendFields(StringBuilder)}. * * @param code * Class code to append enum constants to. */ protected void appendEnumConsts(@Nonnull StringBuilder code) { // Enum constants must come first if the class is an enum. if (AccessFlag.isEnum(classAccess)) { int enumConsts = 0; for (FieldMember field : fields) { if (isEnumConst(field)) { if (enumConsts > 0) code.append(", "); code.append(field.getName()); enumConsts++; } } code.append(';'); } } /** * Appends all non-enum constant fields to the class. * * @param code * Class code to append the fields to. * * @throws ExpressionCompileException * When the fields could not be stubbed out. */ protected void appendFields(@Nonnull StringBuilder code) throws ExpressionCompileException { // Stub out fields / methods for (FieldMember field : fields) { // Skip stubbing compiler-generated fields. if (field.hasBridgeModifier() || field.hasSyntheticModifier()) continue; // Skip enum constants, we added those earlier. if (isEnumConst(field)) continue; // Skip stubbing of illegally named fields. String name = field.getName(); if (!isSafeName(name)) continue; NameType fieldNameType = getInfo(name, field.getDescriptor()); if (!isSafeClassName(fieldNameType.className)) continue; // Skip fields with types that aren't accessible in the workspace. if (isMissingType(field.getDescriptor())) continue; // Append the field. The only modifier that we care about here is if it is static or not. if (field.hasStaticModifier()) code.append("static "); code.append(fieldNameType.className).append(' ').append(fieldNameType.name).append(";\n"); } } /** * Appends all method stubs to the class. * Some methods can be skipped by implementing {@link #doSkipMethod(String, MethodType)}. * * @param code * Class code to append the methods to. * * @throws ExpressionCompileException * When the methods could not be stubbed out. */ protected void appendMethods(@Nonnull StringBuilder code) throws ExpressionCompileException { boolean isEnum = AccessFlag.isEnum(classAccess); for (MethodMember method : methods) { // Skip stubbing compiler-generated methods. if (method.hasBridgeModifier() || method.hasSyntheticModifier()) continue; // Skip stubbing of illegally named methods. String name = method.getName(); boolean isCtor = false; if (name.equals("")) { // Skip constructors for enum classes since we always drop enum const parameters. if (isEnum) continue; isCtor = true; } else if (!isSafeName(name)) continue; // Skip stubbing the method if it is the one we're assembling the expression within. String descriptor = method.getDescriptor(); MethodType localMethodType = Types.methodType(descriptor); if (doSkipMethod(name, localMethodType)) continue; // Skip enum's 'valueOf' + 'values' if (isEnum && name.equals("valueOf") && descriptor.equals("(Ljava/lang/String;)L" + className + ";")) continue; if (isEnum && name.equals("values") && descriptor.equals("()[L" + className + ";")) continue; // Skip stubbing of methods with bad return types / bad parameter types. NameType returnInfo = getInfo(name, localMethodType.returnType().descriptor()); if (!isSafeClassName(returnInfo.className)) continue; List parameterTypes = localMethodType.parameterTypes(); if (!parameterTypes.stream().map(p -> { try { return getInfo("p", p.descriptor()).className(); } catch (Throwable t) { return "\0"; // Bogus which will throw off the safe name check. } }).allMatch(ClassStubGenerator::isSafeClassName)) continue; // Skip methods with return/parameter types that aren't accessible in the workspace. boolean hasMissingType = false; Type[] types = new Type[parameterTypes.size() + 1]; for (int i = 0; i < types.length - 1; i++) types[i] = parameterTypes.get(i); types[parameterTypes.size()] = localMethodType.returnType(); for (Type type : types) { hasMissingType = isMissingType(type); if (hasMissingType) break; } if (hasMissingType) continue; // Stub the method. Start with the access modifiers. if (method.hasPublicModifier()) code.append("public "); else if (method.hasProtectedModifier()) code.append("protected "); else if (method.hasPrivateModifier()) code.append("private "); if (method.hasStaticModifier()) code.append("static "); // Method name. Consider edge case for constructors. if (isCtor) code.append(getLocalName()).append('('); else code.append(returnInfo.className()).append(' ').append(returnInfo.name).append('('); // Add the parameters. We only care about the types, names don't really matter. List methodParameterTypes = parameterTypes; int parameterCount = methodParameterTypes.size(); for (int i = 0; i < parameterCount; i++) { ClassType paramType = methodParameterTypes.get(i); // Skip this parameter if it is an inner class's outer "this" reference if (isCtor && paramType instanceof ObjectType paramObjectType && className.startsWith(paramObjectType.internalName() + '$')) continue; NameType paramInfo = getInfo("p" + i, paramType.descriptor()); code.append(paramInfo.className).append(' ').append(paramInfo.name); if (i < parameterCount - 1) code.append(", "); } code.append(") { "); if (isCtor) { // If we know the parent type, we need to properly implement the constructor. // If we don't know the parent type, we cannot generate a valid constructor. ClassPathNode superPath = superName == null ? null : workspace.findJvmClass(superName); if (superPath == null && superName != null) // Generally this shouldn't happen since we filter the super-name in the constructor. // But just in case we'll keep this error handling here. throw new ExpressionCompileException("Cannot generate 'super(...)' for constructor, " + "missing type information for: " + superName); if (superPath != null) { // To make it easy, we'll find the simplest constructor in the parent class and pass dummy values. // Unlike regular methods we cannot just say 'throw new RuntimeException();' since calling // the 'super(...)' is required. MethodType parentConstructor = superPath.getValue().methodStream() .filter(m -> m.getName().equals("")) .map(m -> Types.methodType(m.getDescriptor())) .min(Comparator.comparingInt(a -> a.parameterTypes().size())) .orElse(null); if (parentConstructor != null) { code.append("super("); parameterCount = parentConstructor.parameterTypes().size(); for (int i = 0; i < parameterCount; i++) { ClassType type = parentConstructor.parameterTypes().get(i); if (type instanceof ObjectType) { code.append("null"); } else { char prim = type.descriptor().charAt(0); if (prim == 'Z') code.append("false"); else code.append('0'); } if (i < parameterCount - 1) code.append(", "); } code.append(");"); } } } else { code.append("throw new RuntimeException();"); } code.append(" }\n"); } } /** * @param code * Class code to append the inner classes to. * * @throws ExpressionCompileException * When the inner classes could not be stubbed out. */ protected void appendInnerClasses(@Nonnull StringBuilder code) throws ExpressionCompileException { for (InnerClassInfo innerClass : innerClasses) { String innerClassName = innerClass.getInnerClassName(); if (!innerClassName.startsWith(className)) continue; if (innerClassName.length() <= className.length()) continue; if (!isSafeClassName(innerClassName.replace('/', '.').replace('$', '.'))) continue; ClassPathNode innerClassPath = workspace.findClass(innerClassName); if (innerClassPath != null) { ClassInfo innerClassInfo = innerClassPath.getValue(); ClassStubGenerator generator = new InnerClassStubGenerator(workspace, inheritanceGraph, // Bitwise or the flags together since we need to know if the inner class is static. // The inner class attribute will say whether it is or not, but the actual class will not. innerClassInfo.getAccess() | (innerClass.getInnerAccess() & Modifier.STATIC), innerClassInfo.getName(), innerClassInfo.getSuperName(), innerClassInfo.getInterfaces(), innerClassInfo.getFields(), innerClassInfo.getMethods(), innerClassInfo.getInnerClasses() ); String inner = generator.generate(); code.append('\n').append(inner).append('\n'); } } } /** * Ends the class definition. * * @param code * Class code to append end to. */ protected void appendClassEnd(@Nonnull StringBuilder code) { // Done with the class code.append("}\n"); } /** * Controls which methods are included in {@link #appendMethods(StringBuilder)}. * * @param name * Method name. * @param type * Method type. * * @return {@code true} to skip. {@code false} to include in output stubbing. */ protected abstract boolean doSkipMethod(@Nonnull String name, @Nonnull MethodType type); /** * @return Modifier to prefix {@code Foo} in {@code class Foo {}}. */ @Nonnull public String getLocalModifier() { return "abstract"; } /** * @return Name string to where {@code Foo} is in {@code class Foo {}}. */ @Nonnull protected String getLocalName() { return StringUtil.shortenPath(className); } /** * @param field * Field to check. * * @return {@code true} when it represents an enum constant. */ protected boolean isEnumConst(@Nonnull FieldMember field) { // This class must be an enum. if (!AccessFlag.isEnum(classAccess)) return false; // The field must be 'public static final' if (!field.hasFinalModifier() || !field.hasStaticModifier() || !field.hasPublicModifier()) return false; // The descriptor must be: L + className + ; if (field.getDescriptor().length() != className.length() + 2) return false; InstanceType fieldDesc = Types.instanceTypeFromDescriptor(field.getDescriptor()); return fieldDesc.internalName().equals(className); } /** * @param vertex * Inheritance vertex to check. * * @return {@code true} if the type is sealed (Defines any permitted subclass). */ private boolean isSealedType(@Nonnull InheritanceVertex vertex) { if (vertex.getValue() instanceof JvmClassInfo cls) { AtomicBoolean result = new AtomicBoolean(false); cls.getClassReader().accept(new SkippingClassVisitor() { @Override public void visitPermittedSubclass(String permittedSubclass) { result.set(true); } }, ClassReader.SKIP_DEBUG); return result.get(); } return false; } /** * @param descriptor * Some non-method descriptor. * * @return {@code true} if the type in the descriptor is found in the {@link #workspace}. */ protected boolean isMissingType(@Nonnull String descriptor) { Type type = Types.typeFromDescriptor(descriptor); return isMissingType(type); } /** * @param type * Some non-method type. * * @return {@code true} if the type in the descriptor is found in the {@link #workspace}. */ protected boolean isMissingType(@Nonnull Type type) { if (type instanceof InstanceType instanceType && workspace.findClass(instanceType.internalName()) == null) return true; else return type instanceof ArrayType arrayType && arrayType.rootComponentType() instanceof InstanceType instanceType && workspace.findClass(instanceType.internalName()) == null; } /** * @param name * Class name to check. * * @return The class name if it is safe to reference, otherwise {@code null}. */ private boolean isSafeReferencableName(@Nullable String name) { if (name == null) return false; // Must be well-formed if (!isSafeInternalClassName(name)) return false; // Must be found in the workspace return workspace.findClass(name) != null; } /** * @param name * Name to check. * * @return {@code true} when it can be used as a variable name safely. */ protected static boolean isSafeName(@Nonnull String name) { // Name must not be empty. if (name.isEmpty()) return false; // Must be comprised of valid identifier characters. char first = name.charAt(0); if (!Character.isJavaIdentifierStart(first)) return false; char[] chars = name.toCharArray(); for (int i = 1; i < chars.length; i++) { if (!Character.isJavaIdentifierPart(chars[i])) return false; } // Cannot be a reserved keyword. return !Keywords.getKeywords().contains(name); } /** * @param internalName * Name to check. Expected to be in the internal format. IE {@code java/lang/String}. * * @return {@code true} when it can be used as a class name safely. */ protected static boolean isSafeInternalClassName(@Nonnull String internalName) { // Sanity check input if (internalName.indexOf('.') >= 0) throw new IllegalStateException("Saw source name format, expected internal name format"); // Extending record directly is not allowed if ("java/lang/Record".equals(internalName)) return false; // All package name portions and the class name must be valid names. return StringUtil.fastSplit(internalName, true, '/').stream() .allMatch(ClassStubGenerator::isSafeName); } /** * @param name * Name to check. Expected to be in the source format. IE {@code java.lang.String}. * * @return {@code true} when it can be used as a class name safely. */ protected static boolean isSafeClassName(@Nonnull String name) { // Sanity check input if (name.indexOf('/') >= 0) throw new IllegalStateException("Saw internal name format, expected source name format"); // Strip array dimensions if (name.endsWith("[]")) name = name.substring(0, name.indexOf('[')); // Allow primitives if (software.coley.recaf.util.Types.isPrimitiveClassName(name)) return true; // All package name portions and the class name must be valid names. return StringUtil.fastSplit(name, true, '.').stream() .allMatch(ClassStubGenerator::isSafeName); } /** * @param name * Variable name. * @param descriptor * Variable descriptor. * * @return Variable info wrapper. * * @throws ExpressionCompileException * When the variable descriptor is malformed. */ @Nonnull protected static NameType getInfo(@Nonnull String name, @Nonnull String descriptor) throws ExpressionCompileException { int size; String className; if (Types.isPrimitive(descriptor)) { PrimitiveType primitiveType = Types.primitiveFromDesc(descriptor); size = Types.category(primitiveType); className = primitiveType.name(); } else if (descriptor.charAt(0) == '[') { ArrayType arrayParameterType = Types.arrayTypeFromDescriptor(descriptor); ClassType componentReturnType = arrayParameterType.componentType(); if (componentReturnType instanceof PrimitiveType primitiveParameter) { className = primitiveParameter.name(); } else if (componentReturnType instanceof InstanceType instanceType) { className = instanceType.internalName().replace('/', '.').replace('$', '.'); } else { throw new ExpressionCompileException("Illegal component type: " + componentReturnType); } className += "[]".repeat(arrayParameterType.dimensions()); size = 1; } else { size = 1; className = Types.instanceTypeFromDescriptor(descriptor).internalName().replace('/', '.').replace('$', '.'); } return new NameType(size, name, className); } /** * Wrapper for field/variable info. * * @param size * Variable slot size. * @param name * Variable name. * @param className * Variable class type name. */ protected record NameType(int size, @Nonnull String name, @Nonnull String className) { } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/compile/stub/ExpressionHostingClassStubGenerator.java ================================================ package software.coley.recaf.services.compile.stub; import dev.xdark.blw.type.ArrayType; import dev.xdark.blw.type.ClassType; import dev.xdark.blw.type.InstanceType; import dev.xdark.blw.type.MethodType; import dev.xdark.blw.type.PrimitiveType; import dev.xdark.blw.type.Types; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.slf4j.Logger; import regexodus.Matcher; import regexodus.Pattern; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.info.InnerClassInfo; import software.coley.recaf.info.member.BasicLocalVariable; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.services.assembler.ExpressionCompileException; import software.coley.recaf.services.assembler.ExpressionCompiler; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.services.inheritance.InheritanceVertex; import software.coley.recaf.util.AccessFlag; import software.coley.recaf.util.RegexUtil; import software.coley.recaf.workspace.model.Workspace; import java.util.HashSet; import java.util.List; import java.util.Set; /** * Class stub generator which implements a specific method with a user-defined expression. * * @author Matt Coley * @see ExpressionCompiler#compile(String) */ public class ExpressionHostingClassStubGenerator extends ClassStubGenerator { private static final Logger logger = Logging.get(ExpressionHostingClassStubGenerator.class); private static final Pattern IMPORT_EXTRACT_PATTERN = RegexUtil.pattern("^\\s*(import \\w.+;)"); private final int methodFlags; private final String methodName; private final MethodType methodType; private final List methodVariables; private final String expression; /** * @param workspace * Workspace to pull class information from. * @param inheritanceGraph * Inheritance graph of the workspace. * @param classAccess * Host class access modifiers. * @param className * Host class name. * @param superName * Host class super name. * @param implementing * Host class interfaces implemented. * @param fields * Host class declared fields. * @param methods * Host class declared methods. * @param innerClasses * Host class declared inner classes. * @param methodFlags * Expression hosting method's access modifiers. * @param methodName * Expression hosting method's name. * @param methodType * Expression hosting method arguments + return type. * @param methodVariables * Expression hosting method's local variables. * @param expression * The expression to insert into the target hosting method. */ public ExpressionHostingClassStubGenerator(@Nonnull Workspace workspace, @Nonnull InheritanceGraph inheritanceGraph, int classAccess, @Nonnull String className, @Nullable String superName, @Nonnull List implementing, @Nonnull List fields, @Nonnull List methods, @Nonnull List innerClasses, int methodFlags, @Nonnull String methodName, @Nonnull MethodType methodType, @Nonnull List methodVariables, @Nonnull String expression) { super(workspace, inheritanceGraph, classAccess, className, superName, implementing, fields, methods, innerClasses); // Map edge cases for disallowed names. if (methodName.equals("")) methodName = "instance_ctor"; else if (methodName.equals("")) methodName = "static_ctor"; else if (!isSafeName(methodName)) methodName = "obfuscated_method"; else if (AccessFlag.isEnum(classAccess) && isReservedEnumMethodName(methodName)) methodName = "enum_method"; // Assign expression host method details this.methodFlags = methodFlags; this.methodName = methodName; this.methodType = methodType; this.methodVariables = methodVariables; this.expression = expression; } @Override public String generate() throws ExpressionCompileException { String localExpression = expression; StringBuilder code = new StringBuilder(); appendPackage(code); localExpression = appendExpressionImports(code, localExpression); appendClassStructure(code); appendEnumConsts(code); appendExpressionMethod(code, localExpression); appendFields(code); appendMethods(code); appendInnerClasses(code); appendClassEnd(code); return code.toString(); } @Override protected boolean doSkipMethod(@Nonnull String name, @Nonnull MethodType type) { // We want to skip generating a stub of the method our expression will reside within. return methodName.equals(name) && methodType.equals(type); } /** * @return Adapted method name for compiler-safe use. */ @Nonnull public String getAdaptedMethodName() { return methodName; } /** * Expressions can contain imports at the top so that the end-user can work without needing fully qualified names. * We want to take those out and append them to the class we're generating, and update the expression to remove * the imports so that we can slap it into the method body later without syntax issues coming from imports being * used in a method body. * * @param code * Class code to append imports to. * @param expression * Expression to extract imports from. * * @return Modified expression (without imports) */ @Nonnull private String appendExpressionImports(@Nonnull StringBuilder code, @Nonnull String expression) { // Add imports from the user defined expression. // Remove the imports from the expression once copied to the output code. StringBuilder expressionBuffer = new StringBuilder(); expression.lines().forEach(l -> { Matcher matcher = IMPORT_EXTRACT_PATTERN.matcher(l); if (matcher.find()) { code.append(matcher.group(1)).append('\n'); } else { expressionBuffer.append(l).append('\n'); } }); return expressionBuffer.toString(); } /** * @param code * Class code to append method definition to. * @param expression * User-defined expression. * * @throws ExpressionCompileException * When the expression hosting method could not be fully generated. */ private void appendExpressionMethod(@Nonnull StringBuilder code, @Nonnull String expression) throws ExpressionCompileException { // Need to build the method structure to house the expression. // We'll start off with the access level. int parameterVarIndex = 0; if (AccessFlag.isPublic(methodFlags)) code.append("public "); else if (AccessFlag.isProtected(methodFlags)) code.append("protected "); else if (AccessFlag.isPrivate(methodFlags)) code.append("private "); if (AccessFlag.isStatic(methodFlags)) code.append("static "); else parameterVarIndex++; // Add the return type. ClassType returnType = methodType.returnType(); if (returnType instanceof PrimitiveType primitiveReturn) { code.append(primitiveReturn.name()).append(' '); } else if (returnType instanceof InstanceType instanceType) { code.append(instanceType.internalName().replace('/', '.')).append(' '); } else if (returnType instanceof ArrayType arrayReturn) { ClassType componentReturnType = arrayReturn.componentType(); if (componentReturnType instanceof PrimitiveType primitiveReturn) { code.append(primitiveReturn.name()); } else if (componentReturnType instanceof InstanceType instanceType) { code.append(instanceType.internalName().replace('/', '.')); } code.append("[]".repeat(arrayReturn.dimensions())); } // Now the method name. code.append(' ').append(methodName).append('('); // And now the parameters. int parameterCount = methodType.parameterTypes().size(); Set usedVariables = new HashSet<>(); for (int i = 0; i < parameterCount; i++) { // Lookup the parameter variable LocalVariable parameterVariable = getParameterVariable(parameterVarIndex, i); String parameterName = parameterVariable.getName(); // Record the parameter as being used usedVariables.add(parameterName); // Skip if the parameter is illegally named. if (!isSafeName(parameterName)) continue; // Skip parameters with types that aren't accessible in the workspace. String descriptor = parameterVariable.getDescriptor(); if (isMissingType(descriptor)) continue; // Append the parameter. NameType varInfo = getInfo(parameterName, descriptor); parameterVarIndex += varInfo.size(); code.append(varInfo.className()).append(' ').append(varInfo.name()); if (i < parameterCount - 1) code.append(", "); } for (LocalVariable variable : methodVariables) { String name = variable.getName(); // Skip illegal named variables and the implicit 'this' if (!isSafeName(name) || name.equals("this")) continue; // Skip if we already included the parameter in the loop above. boolean hasPriorParameters = !usedVariables.isEmpty(); if (!usedVariables.add(name)) continue; // Skip parameters with types that aren't accessible in the workspace. String descriptor = variable.getDescriptor(); if (isMissingType(descriptor)) continue; // Append the parameter. NameType varInfo = getInfo(name, descriptor); if (hasPriorParameters) code.append(", "); code.append(varInfo.className()).append(' ').append(varInfo.name()); } // If we skipped the last parameter for some reason we need to remove the trailing ', ' before closing // off the parameters section. if (code.substring(code.length() - 2).endsWith(", ")) code.setLength(code.length() - 2); // Close off declaration and add a 'throws Throwable' so the user doesn't need to specify try-catch. // If the method is a library method (something we cannot control, like Object.toString()) then // unfortunately we cannot add the 'throws'. InheritanceVertex classVertex = inheritanceGraph.getVertex(className); if (classVertex != null && classVertex.isLibraryMethod(methodName, methodType.descriptor())) code.append(") { " + ExpressionCompiler.EXPR_MARKER + " \n"); else code.append(") throws Throwable { " + ExpressionCompiler.EXPR_MARKER + " \n"); code.append(expression); code.append("}\n"); } /** * Note: The logic for appending parameters to the desc within this method must align with {@link #generate()}. * * @return The method descriptor with additional parameters from the {@link #methodVariables} appended at the end. * * @throws ExpressionCompileException * When parameter variable information cannot be found. */ @Nonnull public String methodDescriptorWithVariables() throws ExpressionCompileException { StringBuilder sb = new StringBuilder("("); int parameterVarIndex = AccessFlag.isStatic(methodFlags) ? 0 : 1; int parameterCount = methodType.parameterTypes().size(); Set usedVariables = new HashSet<>(); for (int i = 0; i < parameterCount; i++) { LocalVariable parameterVariable = getParameterVariable(parameterVarIndex, i); String parameterName = parameterVariable.getName(); usedVariables.add(parameterName); if (!isSafeName(parameterName)) continue; String descriptor = parameterVariable.getDescriptor(); if (isMissingType(descriptor)) continue; NameType varInfo = getInfo(parameterName, descriptor); parameterVarIndex += varInfo.size(); sb.append(descriptor); } for (LocalVariable variable : methodVariables) { String name = variable.getName(); if (!isSafeName(name) || name.equals("this")) continue; if (!usedVariables.add(name)) continue; String descriptor = variable.getDescriptor(); if (isMissingType(descriptor)) continue; sb.append(descriptor); } sb.append(')').append(methodType.returnType().descriptor()); return sb.toString(); } /** * @param index * Local variable index. * * @return Variable entry from the target method, or {@code null} if not known. */ @Nullable private LocalVariable findVar(int index) { if (methodVariables == null) return null; return methodVariables.stream() .filter(l -> l.getIndex() == index) .findFirst().orElse(null); } /** * @param parameterVarIndex * Local variable index of the parameter. * @param parameterIndex * Parameter index. * * @return Local variable info of the parameter. */ @Nonnull private LocalVariable getParameterVariable(int parameterVarIndex, int parameterIndex) { LocalVariable parameterVariable = findVar(parameterVarIndex); if (parameterVariable == null) { List parameterTypes = methodType.parameterTypes(); ClassType parameterType; if (parameterIndex < parameterTypes.size()) { parameterType = parameterTypes.get(parameterIndex); } else { logger.warn("Could not resolve parameter variable (pVar={}, pIndex={}) in {}", parameterVarIndex, parameterIndex, methodName); parameterType = Types.OBJECT; } parameterVariable = new BasicLocalVariable(parameterVarIndex, "p" + parameterIndex, parameterType.descriptor(), null); } return parameterVariable; } private static boolean isReservedEnumMethodName(@Nonnull String methodName) { return methodName.equals("values") || methodName.equals("valueOf") || methodName.equals("ordinal") || methodName.equals("name") || methodName.equals("describeConstable") || methodName.equals("compareTo") || methodName.equals("equals") || methodName.equals("hashCode"); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/compile/stub/InnerClassStubGenerator.java ================================================ package software.coley.recaf.services.compile.stub; import dev.xdark.blw.type.MethodType; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.InnerClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.services.assembler.ExpressionCompileException; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.util.AccessFlag; import software.coley.recaf.workspace.model.Workspace; import java.util.List; /** * Class stub generator which emits classes under the assumption they are inner classes of an outer class. * * @author Matt Coley */ public class InnerClassStubGenerator extends ClassStubGenerator { /** * @param workspace * Workspace to pull class information from. * @param inheritanceGraph * Inheritance graph of the workspace. * @param classAccess * Host class access modifiers. * @param className * Host class name. * @param superName * Host class super name. * @param implementing * Host class interfaces implemented. * @param fields * Host class declared fields. * @param methods * Host class declared methods. * @param innerClasses * Host class declared inner classes. */ public InnerClassStubGenerator(@Nonnull Workspace workspace, @Nonnull InheritanceGraph inheritanceGraph, int classAccess, @Nonnull String className, @Nullable String superName, @Nonnull List implementing, @Nonnull List fields, @Nonnull List methods, @Nonnull List innerClasses) { super(workspace, inheritanceGraph, classAccess, className, superName, implementing, fields, methods, innerClasses); } @Nonnull @Override protected String getLocalName() { // Will be "OuterClass$TheInner" String localName = super.getLocalName(); // We just want "TheInner" int innerSplit = localName.indexOf('$'); if (innerSplit > 0) localName = localName.substring(innerSplit + 1); return localName; } @Nonnull @Override public String getLocalModifier() { StringBuilder sb = new StringBuilder(); // I've seen this happen in Recaf but cannot reproduce a case outside. // https://stackoverflow.com/questions/19481680/error-illegal-static-declaration-in-inner-class if (AccessFlag.isStatic(classAccess)) sb.append("static"); if (AccessFlag.isAbstract(classAccess)) sb.append(" abstract"); // If the inner class (this context) is not abstract, we do not want to force // it to be abstract in order to allow expressions to do "new Inner()" and stuff. return sb.toString().trim(); } @Override public String generate() throws ExpressionCompileException { StringBuilder code = new StringBuilder(); appendClassStructure(code); appendEnumConsts(code); appendFields(code); appendMethods(code); appendInnerClasses(code); appendClassEnd(code); return code.toString(); } @Override protected boolean doSkipMethod(@Nonnull String name, @Nonnull MethodType type) { // Do not skip any methods return false; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/config/ConfigManager.java ================================================ package software.coley.recaf.services.config; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonPrimitive; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; import jakarta.annotation.Nonnull; import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Observes; import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; import org.slf4j.Logger; import software.coley.collections.Unchecked; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.behavior.PrioritySortable; import software.coley.recaf.cdi.InitializationEvent; import software.coley.recaf.config.ConfigCollectionValue; import software.coley.recaf.config.ConfigContainer; import software.coley.recaf.config.ConfigValue; import software.coley.recaf.config.RestoreAwareConfigContainer; import software.coley.recaf.services.Service; import software.coley.recaf.services.ServiceConfig; import software.coley.recaf.services.file.RecafDirectoriesConfig; import software.coley.recaf.services.json.GsonProvider; import software.coley.recaf.util.TestEnvironment; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.TreeMap; import java.util.concurrent.CopyOnWriteArrayList; /** * Tracker for all {@link ConfigContainer} instances. * * @author Matt Coley */ @ApplicationScoped public class ConfigManager implements Service { public static final String SERVICE_ID = "config-manager"; private static final Logger logger = Logging.get(ConfigManager.class); private final Map containers = new TreeMap<>(); private final List listeners = new CopyOnWriteArrayList<>(); private final ConfigManagerConfig config; private final RecafDirectoriesConfig fileConfig; private final GsonProvider gsonProvider; @Inject public ConfigManager(@Nonnull ConfigManagerConfig config, @Nonnull RecafDirectoriesConfig fileConfig, @Nonnull GsonProvider gsonProvider, @Nonnull Instance containers) { this.config = config; this.fileConfig = fileConfig; this.gsonProvider = gsonProvider; for (ConfigContainer container : containers) registerContainer(container); } private void init(@Observes InitializationEvent event) { load(); } @PreDestroy private void save() { // Skip persisting in test environments if (TestEnvironment.isTestEnv()) return; Gson gson = gsonProvider.getGson(); for (ConfigContainer container : containers.values()) { // Skip writing empty containers if (container.getValues().isEmpty()) continue; // Model the vales into a single object. JsonObject json = new JsonObject(); for (ConfigValue configValue : container.getValues().values()) { try { json.add(configValue.getId(), gson.toJsonTree(configValue.getValue())); } catch (IllegalArgumentException e) { logger.error("Could not find adapter for type: {}", configValue.getType(), e); } catch (Exception e) { logger.error("Failed to save config value: {}", configValue.getId(), e); } } // Write the appropriate path based on the container id. String key = container.getGroupAndId(); Path containerPath = fileConfig.getConfigDirectory().resolve(key + ".json"); try (JsonWriter writer = gson.newJsonWriter(Files.newBufferedWriter(containerPath))) { gson.toJson(json, writer); } catch (IOException e) { logger.error("Failed to save config container: {}", key, e); } } } @SuppressWarnings({"raw", "rawtypes"}) private void load() { // Skip loading in test environments if (TestEnvironment.isTestEnv()) return; Gson gson = gsonProvider.getGson(); for (ConfigContainer container : containers.values()) { String key = container.getGroupAndId(); Path containerPath = fileConfig.getConfigDirectory().resolve(key + ".json"); if (!Files.exists(containerPath)) { if (container instanceof RestoreAwareConfigContainer listener) listener.onNoRestore(); continue; } JsonObject json; try (JsonReader reader = gson.newJsonReader(Files.newBufferedReader(containerPath))) { json = Objects.requireNonNull(gson.fromJson(reader, JsonObject.class)); } catch (Exception ex) { logger.error("Failed to load config container: {}", key, ex); continue; } for (ConfigValue value : container.getValues().values()) { String id = value.getId(); // Skip loading if the file doesn't list the entry. if (!json.has(id)) continue; try { loadValue(gson, container, value, json.get(id)); } catch (IllegalArgumentException e) { logger.error("Could not find adapter for type: {}", value.getType(), e); } catch (Exception e) { logger.error("Failed to load config value: {}.{}", key, id, e); } } // Notify the container it has restored its config values from storage. if (container instanceof RestoreAwareConfigContainer listener) listener.onRestore(); } } @SuppressWarnings({"unchecked", "rawtypes"}) private void loadValue(Gson gson, ConfigContainer container, ConfigValue value, JsonElement element) { // Validate that the value type matches the element type before attempting to load it. // This can happen if the config file is manually edited improperly, or if the config value type was changed between saves. Class valueType = value.getType(); if (element.isJsonPrimitive()) { JsonPrimitive primitive = element.getAsJsonPrimitive(); if ((valueType == String.class && !primitive.isString()) || (Number.class.isAssignableFrom(valueType) && !primitive.isNumber()) || (int.class == valueType && !primitive.isNumber()) || (long.class == valueType && !primitive.isNumber()) || (float.class == valueType && !primitive.isNumber()) || (double.class == valueType && !primitive.isNumber()) || (valueType == Character.class && !primitive.isString()) || (valueType == Boolean.class && !primitive.isBoolean())) { logger.warn("Type mismatch for config value '{}.{}'. Expected {}, but found {}. Skipping value.", container.getGroupAndId(), value.getId(), valueType.getSimpleName(), primitive); return; } } else if (element.isJsonArray() && !valueType.isArray() && !Collection.class.isAssignableFrom(valueType)) { logger.warn("Type mismatch for config value '{}.{}'. Expected {}, but found array. Skipping value.", container.getGroupAndId(), value.getId(), valueType.getSimpleName()); return; } else if (element.isJsonObject() && valueType.isPrimitive()) { logger.warn("Type mismatch for config value '{}.{}'. Expected {}, but found object. Skipping value.", container.getGroupAndId(), value.getId(), valueType.getSimpleName()); return; } // Now that we know the types are compatible, attempt to load the value. if (value instanceof ConfigCollectionValue ccv) { List list = new ArrayList<>(); JsonArray array = element.getAsJsonArray(); for (JsonElement e : array) { list.add(gson.fromJson(e, ccv.getItemType())); } value.setValue(list); } else { value.setValue(gson.fromJson(element, value.getType())); } } /** * @return All registered containers. */ @Nonnull public Collection getContainers() { return containers.values(); } /** * @param container * Container to register. */ public void registerContainer(@Nonnull ConfigContainer container) { String id = container.getId(); if (containers.containsKey(id)) throw new IllegalStateException("Container by ID '" + id + "' already registered"); containers.put(id, container); // Alert listeners when content added Unchecked.checkedForEach(listeners, listener -> listener.onRegister(container), (listener, t) -> logger.error("Exception thrown when registering container '{}'", container.getId(), t)); } /** * @param container * Container to unregister. */ public void unregisterContainer(@Nonnull ConfigContainer container) { ConfigContainer removed = containers.remove(container.getId()); // Alert listeners when content removed if (removed != null) { Unchecked.checkedForEach(listeners, listener -> listener.onUnregister(container), (listener, t) -> logger.error("Exception thrown when unregistering container '{}'", container.getId(), t)); } } /** * @param listener * Listener to add. */ public void addManagedConfigListener(@Nonnull ManagedConfigListener listener) { PrioritySortable.add(listeners, listener); } /** * @param listener * Listener to remove. * * @return {@code true} when the listener was removed. * {@code false} when it wasn't added in the first place. */ public boolean removeManagedConfigListener(@Nonnull ManagedConfigListener listener) { return listeners.remove(listener); } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public ServiceConfig getServiceConfig() { return config; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/config/ConfigManagerConfig.java ================================================ package software.coley.recaf.services.config; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link ConfigManager}. * * @author Matt Coley */ @ApplicationScoped public class ConfigManagerConfig extends BasicConfigContainer implements ServiceConfig { @Inject public ConfigManagerConfig() { super(ConfigGroups.SERVICE, ConfigManager.SERVICE_ID + CONFIG_SUFFIX); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/config/ManagedConfigListener.java ================================================ package software.coley.recaf.services.config; import jakarta.annotation.Nonnull; import software.coley.recaf.behavior.PrioritySortable; import software.coley.recaf.config.ConfigContainer; /** * Listenr for {@link ConfigManager#registerContainer(ConfigContainer)} and * {@link ConfigManager#unregisterContainer(ConfigContainer)} calls. * * @author Matt Coley */ public interface ManagedConfigListener extends PrioritySortable { /** * @param container * Registered config. */ void onRegister(@Nonnull ConfigContainer container); /** * @param container * Unregistered config. */ void onUnregister(@Nonnull ConfigContainer container); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/AbstractAndroidDecompiler.java ================================================ package software.coley.recaf.services.decompile; import jakarta.annotation.Nonnull; /** * Basic setup for {@link AndroidDecompiler}. * * @author Matt Coley */ public abstract class AbstractAndroidDecompiler extends AbstractDecompiler implements AndroidDecompiler { /** * @param name * Decompiler name. * @param version * Decompiler version. * @param config * Decompiler configuration. */ public AbstractAndroidDecompiler(@Nonnull String name, @Nonnull String version, @Nonnull DecompilerConfig config) { super(name, version, config); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/AbstractDecompiler.java ================================================ package software.coley.recaf.services.decompile; import jakarta.annotation.Nonnull; import software.coley.recaf.services.decompile.filter.OutputTextFilter; import java.util.HashSet; import java.util.Set; /** * Base for {@link Decompiler}. * * @author Matt Coley */ public abstract class AbstractDecompiler implements Decompiler { protected final Set textFilters = new HashSet<>(); private final String name; private final String version; private final DecompilerConfig config; /** * @param name * Decompiler name. * @param version * Decompiler version. * @param config * Decompiler configuration. */ public AbstractDecompiler(@Nonnull String name, @Nonnull String version, @Nonnull DecompilerConfig config) { this.name = name; this.version = version; this.config = config; } @Nonnull @Override public String getName() { return name; } @Nonnull @Override public String getVersion() { return version; } @Nonnull @Override public DecompilerConfig getConfig() { return config; } @Override public boolean addOutputTextFilter(@Nonnull OutputTextFilter filter) { return textFilters.add(filter); } @Override public boolean removeOutputTextFilter(@Nonnull OutputTextFilter filter) { return textFilters.remove(filter); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; AbstractDecompiler that = (AbstractDecompiler) o; if (!name.equals(that.name)) return false; if (!version.equals(that.version)) return false; return config.equals(that.config); } @Override public int hashCode() { int result = name.hashCode(); result = 31 * result + version.hashCode(); result = 31 * result + config.hashCode(); return result; } @Override public String toString() { return getName() + " - " + getVersion(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/AbstractJvmDecompiler.java ================================================ package software.coley.recaf.services.decompile; import jakarta.annotation.Nonnull; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.decompile.filter.JvmBytecodeFilter; import software.coley.recaf.services.decompile.filter.OutputTextFilter; import software.coley.recaf.workspace.model.Workspace; import java.util.ArrayList; import java.util.List; /** * Basic setup for {@link JvmDecompiler}. * * @author Matt Coley */ public abstract class AbstractJvmDecompiler extends AbstractDecompiler implements JvmDecompiler { private final List bytecodeFilters = new ArrayList<>(); /** * @param name * Decompiler name. * @param version * Decompiler version. * @param config * Decompiler configuration. */ public AbstractJvmDecompiler(@Nonnull String name, @Nonnull String version, @Nonnull DecompilerConfig config) { super(name, version, config); } @Override public boolean addJvmBytecodeFilter(@Nonnull JvmBytecodeFilter filter) { return bytecodeFilters.add(filter); } @Override public boolean removeJvmBytecodeFilter(@Nonnull JvmBytecodeFilter filter) { return bytecodeFilters.remove(filter); } @Nonnull @Override public final DecompileResult decompile(@Nonnull Workspace workspace, @Nonnull JvmClassInfo classInfo) { // Get bytecode and run through filters. JvmClassInfo filteredBytecode = JvmBytecodeFilter.applyFilters(workspace, classInfo, bytecodeFilters); // Pass to implementation. DecompileResult result = decompileInternal(workspace, filteredBytecode); // Adapt output decompilation if output filters are registered. if (result.getType() == DecompileResult.ResultType.SUCCESS && result.getText() != null && !textFilters.isEmpty()) { String text = result.getText(); for (OutputTextFilter filter : textFilters) text = filter.filter(workspace, classInfo, text); result = result.withText(text); } return result; } /** * Takes on the work of {@link #decompile(Workspace, JvmClassInfo)} after the {@link #bytecodeFilters} have been applied to the class. * * @param workspace * Workspace to pull data from. * @param classInfo * Class to decompile. * * @return Decompilation result. */ @Nonnull protected abstract DecompileResult decompileInternal(@Nonnull Workspace workspace, @Nonnull JvmClassInfo classInfo); @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; if (!super.equals(o)) return false; AbstractJvmDecompiler other = (AbstractJvmDecompiler) o; return bytecodeFilters.equals(other.bytecodeFilters); } @Override public int hashCode() { int result = super.hashCode(); result = 31 * result + bytecodeFilters.hashCode(); return result; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/AndroidDecompiler.java ================================================ package software.coley.recaf.services.decompile; import jakarta.annotation.Nonnull; import software.coley.recaf.info.AndroidClassInfo; import software.coley.recaf.workspace.model.Workspace; /** * Outline for Android/Dalvik decompile capabilities. * * @author Matt Coley */ public interface AndroidDecompiler extends Decompiler { // Placeholder until more fleshed out API is implemented DecompileResult decompile(@Nonnull Workspace workspace, @Nonnull AndroidClassInfo classInfo); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/BaseDecompilerConfig.java ================================================ package software.coley.recaf.services.decompile; import jakarta.annotation.Nonnull; import software.coley.recaf.config.BasicConfigContainer; import static software.coley.recaf.config.ConfigGroups.SERVICE_DECOMPILE_IMPL; /** * Base class for fields needed by all decompiler configurations * * @author therathatter */ public class BaseDecompilerConfig extends BasicConfigContainer implements DecompilerConfig { private int hash = 0; /** * @param id * Container ID. */ public BaseDecompilerConfig(@Nonnull String id) { super(SERVICE_DECOMPILE_IMPL, id); } @Override public int getHash() { return hash; } @Override public void setHash(int hash) { this.hash = hash; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/DecompileResult.java ================================================ package software.coley.recaf.services.decompile; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.properties.builtin.CachedDecompileProperty; import software.coley.recaf.util.StringUtil; import java.util.Objects; /** * Result for a {@link Decompiler} output. * * @author Matt Coley */ public class DecompileResult { private final String text; private final Throwable exception; private final ResultType type; private final int configHash; /** * Constructor for a successful decompilation. * * @param text * Decompiled text. * @param configHash * Value of {@link DecompilerConfig#getHash()} of associated decompiler. * Used to determine if cached value in {@link CachedDecompileProperty} is up-to-date with current config. */ public DecompileResult(@Nonnull String text, int configHash) { this.text = text; this.type = ResultType.SUCCESS; this.configHash = configHash; this.exception = null; } /** * Constructor for a failed decompilation. * * @param exception * Failure reason. * @param configHash * Value of {@link DecompilerConfig#getHash()} of associated decompiler. * Used to determine if cached value in {@link CachedDecompileProperty} is up-to-date with current config. */ public DecompileResult(@Nonnull Throwable exception, int configHash) { this.text = "// " + StringUtil.traceToString(exception).replace("\n", "\n// "); this.type = ResultType.FAILURE; this.configHash = configHash; this.exception = exception; } /** * Constructor for a skipped decompilation. * * @param configHash * Value of {@link DecompilerConfig#getHash()} of associated decompiler. * Used to determine if cached value in {@link CachedDecompileProperty} is up-to-date with current config. */ public DecompileResult(int configHash) { this.text = null; this.type = ResultType.SKIPPED; this.configHash = configHash; this.exception = null; } /** * Constructor for a skipped decompilation, with pre-defined text. * Typically used for displaying feedback if the decompiler had an issue or timed out. * * @param text * Decompiled text. */ public DecompileResult(@Nonnull String text) { this.text = text; this.type = ResultType.SKIPPED; this.configHash = 0; this.exception = null; } /** * Private constructor for wither operations. * * @param text * Decompiled text. * @param exception * Failure reason. * @param type * Result type. * @param configHash * Hash of config used to decompile the code. */ private DecompileResult(String text, Throwable exception, ResultType type, int configHash) { this.text = text; this.exception = exception; this.type = type; this.configHash = configHash; } /** * @param text * New text content. * * @return Copy of result, with new text content. */ @Nonnull public DecompileResult withText(@Nonnull String text) { return new DecompileResult(text, exception, type, configHash); } /** * @return Decompiled text. * May be {@code null} when {@link #getType()} is not {@link ResultType#SUCCESS}. */ @Nullable public String getText() { return text; } /** * @return Failure reason. * May be {@code null} when {@link #getType()} is not {@link ResultType#FAILURE}. */ @Nullable public Throwable getException() { return exception; } /** * @return Result type. */ @Nonnull public ResultType getType() { return type; } /** * @return Value of {@link DecompilerConfig#getHash()} of associated decompiler. * Used to determine if cached value in {@link CachedDecompileProperty} is up-to-date with current config. */ public int getConfigHash() { return configHash; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; DecompileResult that = (DecompileResult) o; if (configHash != that.configHash) return false; if (!Objects.equals(text, that.text)) return false; if (!Objects.equals(exception, that.exception)) return false; return type == that.type; } @Override public int hashCode() { int result = text != null ? text.hashCode() : 0; result = 31 * result + (exception != null ? exception.hashCode() : 0); result = 31 * result + type.hashCode(); result = 31 * result + configHash; return result; } /** * Type of result. */ public enum ResultType { /** * Successful decompilation. */ SUCCESS, /** * Decompilation skipped for some reason. Likely due to a thread being cancelled. */ SKIPPED, /** * Decompilation failed to emit any output. */ FAILURE } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/Decompiler.java ================================================ package software.coley.recaf.services.decompile; import jakarta.annotation.Nonnull; import software.coley.recaf.info.properties.builtin.CachedDecompileProperty; import software.coley.recaf.services.decompile.filter.OutputTextFilter; /** * Common decompiler operations. * * @author Matt Coley * @see JvmDecompiler For decompiling JVM bytecode. * @see AndroidDecompiler For decompiling Android/Dalvik bytecode. * @see DecompilerConfig For config management of decompiler values, * and ensuring {@link CachedDecompileProperty} values are compatible with current settings. */ public interface Decompiler { /** * @return Decompiler name. */ @Nonnull String getName(); /** * @return Decompiler version. */ @Nonnull String getVersion(); /** * @return Decompiler config. */ @Nonnull DecompilerConfig getConfig(); /** * Adds a filter which operates on the decompiler output, before the contents are returned to the user. * * @param filter * Filter to add. * * @return {@code true} on successful addition. * {@code false} if the filter has already been added. */ boolean addOutputTextFilter(@Nonnull OutputTextFilter filter); /** * Removes a filter which operates on the decompiler output, before the contents are returned to the user. * * @param filter * Filter to remove. * * @return {@code true} on successful removal. * {@code false} if the filter was not already registered. */ boolean removeOutputTextFilter(@Nonnull OutputTextFilter filter); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/DecompilerConfig.java ================================================ package software.coley.recaf.services.decompile; import software.coley.recaf.config.ConfigContainer; import software.coley.recaf.config.ConfigValue; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.properties.builtin.CachedDecompileProperty; /** * Subtype of {@link ConfigContainer} for use by {@link Decompiler} implementations. *
* Tracks the hash of all contained {@link ConfigValue} so that when decompilers check for * {@link CachedDecompileProperty} they can see if the {@link DecompileResult#getConfigHash()} * matches the current one of {@link #getHash()}. * * @author Matt Coley */ public interface DecompilerConfig extends ConfigContainer { /** * This value is compared to {@link DecompileResult#getConfigHash()} when a {@link Decompiler} implementation * looks to decompile a {@link ClassInfo} and finds an existing entry in {@link CachedDecompileProperty}. *
* If the values match, the cached result can be used. * Otherwise, the result must be ignored since the config difference can yield a different result. * * @return Unique hash of all contained {@link ConfigValue}. */ int getHash(); /** * @param hash * New hash value. * * @see #getHash() For more detail. */ void setHash(int hash); /** * Called by implementations after they add all their values to the container. * * @see #getHash() For more detail. */ default void registerConfigValuesHashUpdates() { // Initial value computation. update(); // Register listeners to ensure hash is up-to-date. getValues().values().forEach(value -> value.getObservable().addChangeListener((ob, old, cur) -> update())); } private void update() { getValues().values().stream() .map(ConfigValue::getValue) .mapToInt(value -> value == null ? 0 : value.hashCode()) .reduce((a, b) -> (31 * a) + b) .ifPresent(this::setHash); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/DecompilerManager.java ================================================ package software.coley.recaf.services.decompile; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; import org.jboss.weld.util.LazyValueHolder; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.ClassWriter; import software.coley.observables.ObservableObject; import software.coley.observables.ObservableString; import software.coley.recaf.analytics.logging.DebuggingLogger; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.info.AndroidClassInfo; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.properties.builtin.CachedDecompileProperty; import software.coley.recaf.services.Service; import software.coley.recaf.services.decompile.filter.JvmBytecodeFilter; import software.coley.recaf.services.decompile.filter.OutputTextFilter; import software.coley.recaf.util.threading.ThreadPoolFactory; import software.coley.recaf.util.visitors.BogusNameRemovingVisitor; import software.coley.recaf.util.visitors.ClassHollowingVisitor; import software.coley.recaf.util.visitors.DuplicateAnnotationRemovingVisitor; import software.coley.recaf.util.visitors.IllegalAnnotationRemovingVisitor; import software.coley.recaf.util.visitors.IllegalSignatureRemovingVisitor; import software.coley.recaf.util.visitors.LongAnnotationRemovingVisitor; import software.coley.recaf.util.visitors.LongExceptionRemovingVisitor; import software.coley.recaf.util.visitors.SyntheticRemovingVisitor; import software.coley.recaf.workspace.model.Workspace; import java.util.Collection; import java.util.Collections; import java.util.EnumSet; import java.util.List; import java.util.Map; import java.util.TreeMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutorService; /** * Manager of multiple {@link Decompiler} instances. * * @author Matt Coley */ @ApplicationScoped public class DecompilerManager implements Service { public static final String SERVICE_ID = "decompilers"; private static final DebuggingLogger logger = Logging.get(DecompilerManager.class); private static final NoopJvmDecompiler NO_OP_JVM = NoopJvmDecompiler.getInstance(); private static final NoopAndroidDecompiler NO_OP_ANDROID = NoopAndroidDecompiler.getInstance(); private final JvmBytecodeFilter layeredJvmFilter = new LayeredJvmBytecodeFilter(); private final ExecutorService decompileThreadPool = ThreadPoolFactory.newFixedThreadPool(SERVICE_ID); private final List bytecodeFilters = new CopyOnWriteArrayList<>(); private final List outputTextFilters = new CopyOnWriteArrayList<>(); private final Map jvmDecompilers = new TreeMap<>(); private final Map androidDecompilers = new TreeMap<>(); private final DecompilerManagerConfig config; private final ObservableObject targetJvmDecompiler; private final ObservableObject targetAndroidDecompiler; /** * @param config * Config to pull values from. * @param implementations * CDI provider of decompiler implementations. */ @Inject public DecompilerManager(@Nonnull DecompilerManagerConfig config, @Nonnull Instance implementations) { this.config = config; // Register implementations for (Decompiler implementation : implementations) { if (implementation instanceof JvmDecompiler jvmDecompiler) { register(jvmDecompiler); } else if (implementation instanceof AndroidDecompiler androidDecompiler) { register(androidDecompiler); } } ObservableString preferredJvmDecompiler = config.getPreferredJvmDecompiler(); ObservableString preferredAndroidDecompiler = config.getPreferredAndroidDecompiler(); // Mirror properties from config, mapped to instances targetJvmDecompiler = preferredJvmDecompiler .mapObject(key -> jvmDecompilers.getOrDefault(key == null ? "" : key, NO_OP_JVM)); targetAndroidDecompiler = preferredAndroidDecompiler .mapObject(key -> androidDecompilers.getOrDefault(key == null ? "" : key, NO_OP_ANDROID)); // Select first item if no value is present if (preferredJvmDecompiler.getValue() == null) { JvmDecompiler decompiler = jvmDecompilers.isEmpty() ? NO_OP_JVM : jvmDecompilers.values().iterator().next(); preferredJvmDecompiler.setValue(decompiler.getName()); } if (preferredAndroidDecompiler.getValue() == null) { AndroidDecompiler decompiler = androidDecompilers.isEmpty() ? NO_OP_ANDROID : androidDecompilers.values().iterator().next(); preferredAndroidDecompiler.setValue(decompiler.getName()); } } /** * Uses the built-in thread-pool to schedule the decompilation with the {@link #getTargetJvmDecompiler()}. * * @param workspace * Workspace to pull additional information from. * @param classInfo * Class to decompile. * * @return Future of decompilation result. */ @Nonnull public CompletableFuture decompile(@Nonnull Workspace workspace, @Nonnull JvmClassInfo classInfo) { return decompile(getTargetJvmDecompiler(), workspace, classInfo); } /** * Uses the built-in thread-pool to schedule the decompilation. * * @param decompiler * Decompiler implementation to use. * @param workspace * Workspace to pull additional information from. * @param classInfo * Class to decompile. * * @return Future of decompilation result. */ @Nonnull public CompletableFuture decompile(@Nonnull JvmDecompiler decompiler, @Nonnull Workspace workspace, @Nonnull JvmClassInfo classInfo) { return CompletableFuture.supplyAsync(() -> { boolean doCache = config.getCacheDecompilations().getValue(); if (doCache) { // Check for cached result, returning the cached result if found // and only if the current config matches the one that yielded the cached result. DecompileResult cachedResult = CachedDecompileProperty.get(classInfo, decompiler); if (cachedResult != null) { if (cachedResult.getConfigHash() == decompiler.getConfig().getHash()) return cachedResult; // Config changed, void the cache. CachedDecompileProperty.remove(classInfo); } } // We will use the layered filter manually here so any user requested cleanup is done before we pass the class to the decompiler. // The decompiler base implementation skips some work if there are no registered filters so doing it externally like this is // better for performance. If the user has no filtering enabled then no re-reads and re-writes are necessary. JvmClassInfo filteredClass = JvmBytecodeFilter.applyFilters(workspace, classInfo, Collections.singletonList(layeredJvmFilter)); // Decompile and cache the results. DecompileResult result = decompiler.decompile(workspace, filteredClass); String decompilation = result.getText(); if (decompilation != null && !outputTextFilters.isEmpty()) { // Apply output filters and re-wrap the result with the new output text. for (OutputTextFilter textFilter : outputTextFilters) decompilation = textFilter.filter(workspace, classInfo, decompilation); result = new DecompileResult(decompilation, result.getConfigHash()); } if (doCache) CachedDecompileProperty.set(classInfo, decompiler, result); return result; }, decompileThreadPool); } /** * Uses the built-in thread-pool to schedule the decompilation with the {@link #getTargetAndroidDecompiler()}. * * @param workspace * Workspace to pull additional information from. * @param classInfo * Class to decompile. * * @return Future of decompilation result. */ @Nonnull public CompletableFuture decompile(@Nonnull Workspace workspace, @Nonnull AndroidClassInfo classInfo) { return decompile(getTargetAndroidDecompiler(), workspace, classInfo); } /** * Uses the built-in thread-pool to schedule the decompilation. * * @param decompiler * Decompiler implementation to use. * @param workspace * Workspace to pull additional information from. * @param classInfo * Class to decompile. * * @return Future of decompilation result. */ @Nonnull public CompletableFuture decompile(@Nonnull AndroidDecompiler decompiler, @Nonnull Workspace workspace, @Nonnull AndroidClassInfo classInfo) { return CompletableFuture.supplyAsync(() -> decompiler.decompile(workspace, classInfo), decompileThreadPool); } /** * Adds an input bytecode filter to all {@link JvmDecompiler} instances. * * @param filter * Filter to add. */ public void addJvmBytecodeFilter(@Nonnull JvmBytecodeFilter filter) { bytecodeFilters.add(filter); } /** * Removes an input bytecode filter from all {@link JvmDecompiler} instances. * * @param filter * Filter to remove. */ public void removeJvmBytecodeFilter(@Nonnull JvmBytecodeFilter filter) { bytecodeFilters.remove(filter); } /** * Adds an output text filter to all {@link Decompiler} instances. * * @param filter * Filter to add. */ public void addOutputTextFilter(@Nonnull OutputTextFilter filter) { outputTextFilters.add(filter); } /** * Removes an output text filter from all {@link Decompiler} instances. * * @param filter * Filter to remove. */ public void removeOutputTextFilter(@Nonnull OutputTextFilter filter) { outputTextFilters.remove(filter); } /** * @return Preferred JVM decompiler. */ @Nonnull public JvmDecompiler getTargetJvmDecompiler() { return targetJvmDecompiler.getValue(); } /** * @return Preferred Android decompiler. */ @Nonnull public AndroidDecompiler getTargetAndroidDecompiler() { return targetAndroidDecompiler.getValue(); } /** * @param decompiler * JVM decompiler to add. */ public void register(@Nonnull JvmDecompiler decompiler) { jvmDecompilers.put(decompiler.getName(), decompiler); } /** * @param decompiler * Android decompiler to add. */ public void register(@Nonnull AndroidDecompiler decompiler) { androidDecompilers.put(decompiler.getName(), decompiler); } /** * @param name * Name of decompiler. * * @return Decompiler instance, or {@code null} if nothing by the ID was found. */ @Nullable public JvmDecompiler getJvmDecompiler(@Nonnull String name) { return jvmDecompilers.get(name); } /** * @param name * Name of decompiler. * * @return Decompiler instance, or {@code null} if nothing by the ID was found. */ @Nullable public AndroidDecompiler getAndroidDecompiler(@Nonnull String name) { return androidDecompilers.get(name); } /** * @return Available JVM class decompilers. */ @Nonnull public Collection getJvmDecompilers() { return jvmDecompilers.values(); } /** * @return Available android class decompilers. */ @Nonnull public Collection getAndroidDecompilers() { return androidDecompilers.values(); } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public DecompilerManagerConfig getServiceConfig() { return config; } /** * JVM bytecode filter that applies multiple other filters: *
    *
  1. Any values in {@link #bytecodeFilters}
  2. *
  3. Any filters based on the current values in {@link #config}
  4. *
*/ private class LayeredJvmBytecodeFilter implements JvmBytecodeFilter { @Nonnull @Override public byte[] filter(@Nonnull Workspace workspace, @Nonnull JvmClassInfo initialClassInfo, @Nonnull byte[] bytecode) { // Apply filters to the input bytecode first for (JvmBytecodeFilter filter : bytecodeFilters) bytecode = filter.filter(workspace, initialClassInfo, bytecode); // Setup filtering based on config byte[] filteredBytecode = bytecode; LazyValueHolder reader = LazyValueHolder.forSupplier(() -> new ClassReader(filteredBytecode)); LazyValueHolder cw = LazyValueHolder.forSupplier(() -> { // In most cases we want to pass the class-reader along to the class-writer. // This will allow some operations to be sped up internally by ASM. // // However, we can't do this is we're filtering debug information since it blanket copies all // debug attribute information without checking what the class-reader flags are. // Thus, when we're pruning debug info, we should pass 'null'. ClassReader backing = config.getFilterDebug().getValue() ? null : reader.get(); return new ClassWriter(backing, 0); }); ClassVisitor cv = null; // The things you want to 'filter' first need to appear last in this chain since we're building a chain // of visitors which delegate from one onto another. if (config.getFilterNonAsciiNames().getValue()) { cv = cw.get(); cv = BogusNameRemovingVisitor.create(workspace, cv); } if (config.getFilterLongAnnotations().getValue()) { if (cv == null) cv = cw.get(); cv = new LongAnnotationRemovingVisitor(cv, config.getFilterLongAnnotationsLength().getValue()); } if (config.getFilterLongExceptions().getValue()) { if (cv == null) cv = cw.get(); cv = new LongExceptionRemovingVisitor(cv, config.getFilterLongExceptionsLength().getValue()); } if (config.getFilterDuplicateAnnotations().getValue()) { if (cv == null) cv = cw.get(); cv = new DuplicateAnnotationRemovingVisitor(cv); } if (config.getFilterIllegalAnnotations().getValue()) { if (cv == null) cv = cw.get(); cv = new IllegalAnnotationRemovingVisitor(cv); } if (config.getFilterHollow().getValue()) { if (cv == null) cv = cw.get(); cv = new ClassHollowingVisitor(cv, EnumSet.allOf(ClassHollowingVisitor.Item.class)); } if (config.getFilterSignatures().getValue()) { if (cv == null) cv = cw.get(); cv = new IllegalSignatureRemovingVisitor(cv); } if (config.getFilterSynthetics().getValue()) { if (cv == null) cv = cw.get(); cv = new SyntheticRemovingVisitor(cv); } if (config.getFilterDebug().getValue() && cv == null) cv = cw.get(); // If no filtering has been requested, we never need to initialize the reader or writer. // Just return the original bytecode passed in. if (cv == null) return bytecode; try { int readFlags = config.getFilterDebug().getValue() ? ClassReader.SKIP_DEBUG : 0; reader.get().accept(cv, readFlags); return cw.get().toByteArray(); } catch (Throwable t) { logger.error("Error applying filters to class '{}'", initialClassInfo.getName(), t); return bytecode; } } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/DecompilerManagerConfig.java ================================================ package software.coley.recaf.services.decompile; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.observables.ObservableBoolean; import software.coley.observables.ObservableInteger; import software.coley.observables.ObservableString; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.BasicConfigValue; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link DecompilerManager} * * @author Matt Coley */ @ApplicationScoped public class DecompilerManagerConfig extends BasicConfigContainer implements ServiceConfig { public static final String KEY_PREF_JVM_DECOMPILER = "pref-jvm-decompiler"; public static final String KEY_PREF_ANDROID_DECOMPILER = "pref-android-decompiler"; private final ObservableString preferredJvmDecompiler = new ObservableString(null); private final ObservableString preferredAndroidDecompiler = new ObservableString(null); private final ObservableBoolean cacheDecompilations = new ObservableBoolean(true); private final ObservableBoolean filterDebug = new ObservableBoolean(false); private final ObservableBoolean filterHollow = new ObservableBoolean(false); private final ObservableBoolean filterIllegalAnnotations = new ObservableBoolean(false); private final ObservableBoolean filterDuplicateAnnotations = new ObservableBoolean(false); private final ObservableBoolean filterLongAnnotations = new ObservableBoolean(false); private final ObservableInteger filterLongAnnotationsLength = new ObservableInteger(256); private final ObservableBoolean filterLongExceptions = new ObservableBoolean(false); private final ObservableInteger filterLongExceptionsLength = new ObservableInteger(256); private final ObservableBoolean filterSignatures = new ObservableBoolean(false); private final ObservableBoolean filterSynthetics = new ObservableBoolean(false); private final ObservableBoolean filterNonAsciiNames = new ObservableBoolean(false); @Inject public DecompilerManagerConfig() { super(ConfigGroups.SERVICE_DECOMPILE, DecompilerManager.SERVICE_ID + CONFIG_SUFFIX); // Add values addValue(new BasicConfigValue<>(KEY_PREF_JVM_DECOMPILER, String.class, preferredJvmDecompiler)); addValue(new BasicConfigValue<>(KEY_PREF_ANDROID_DECOMPILER, String.class, preferredAndroidDecompiler)); addValue(new BasicConfigValue<>("cache-decompilations", boolean.class, cacheDecompilations)); addValue(new BasicConfigValue<>("filter-strip-debug", boolean.class, filterDebug)); addValue(new BasicConfigValue<>("filter-hollow", boolean.class, filterHollow)); addValue(new BasicConfigValue<>("filter-annotations-illegal", boolean.class, filterIllegalAnnotations)); addValue(new BasicConfigValue<>("filter-annotations-duplicate", boolean.class, filterDuplicateAnnotations)); addValue(new BasicConfigValue<>("filter-annotations-long", boolean.class, filterLongAnnotations)); addValue(new BasicConfigValue<>("filter-annotations-long-limit", int.class, filterLongAnnotationsLength)); addValue(new BasicConfigValue<>("filter-exceptions-long", boolean.class, filterLongExceptions)); addValue(new BasicConfigValue<>("filter-exceptions-long-limit", int.class, filterLongExceptionsLength)); addValue(new BasicConfigValue<>("filter-illegal-signatures", boolean.class, filterSignatures)); addValue(new BasicConfigValue<>("filter-synthetics", boolean.class, filterSynthetics)); addValue(new BasicConfigValue<>("filter-names-ascii", boolean.class, filterNonAsciiNames)); } /** * @return {@link JvmDecompiler#getName()} for preferred JVM decompiler to use in {@link DecompilerManager}. */ @Nonnull public ObservableString getPreferredJvmDecompiler() { return preferredJvmDecompiler; } /** * @return {@link AndroidDecompiler#getName()} for preferred JVM decompiler to use in {@link DecompilerManager}. */ @Nonnull public ObservableString getPreferredAndroidDecompiler() { return preferredAndroidDecompiler; } /** * @return {@code true} to cache the results of decompilation tasks in via the {@link DecompilerManager}. */ @Nonnull public ObservableBoolean getCacheDecompilations() { return cacheDecompilations; } /** * @return {@code true} to filter out all debug information including generics, line numbers, variable names, etc. */ @Nonnull public ObservableBoolean getFilterDebug() { return filterDebug; } /** * @return {@code true} */ @Nonnull public ObservableBoolean getFilterHollow() { return filterHollow; } /** * @return {@code true} to filter out illegally typed annotations. */ @Nonnull public ObservableBoolean getFilterIllegalAnnotations() { return filterIllegalAnnotations; } /** * @return {@code true} to filter out duplicate annotations applied to classes/fields/methods. */ @Nonnull public ObservableBoolean getFilterDuplicateAnnotations() { return filterDuplicateAnnotations; } /** * @return {@code true} to filter out long named annotations. */ @Nonnull public ObservableBoolean getFilterLongAnnotations() { return filterLongAnnotations; } /** * @return Max name length to allowed for {@link #getFilterLongAnnotations()}. */ @Nonnull public ObservableInteger getFilterLongAnnotationsLength() { return filterLongAnnotationsLength; } /** * @return {@code true} to filter out long named exceptions. */ @Nonnull public ObservableBoolean getFilterLongExceptions() { return filterLongExceptions; } /** * @return Max name length to allowed for {@link #getFilterLongExceptions()}. */ @Nonnull public ObservableInteger getFilterLongExceptionsLength() { return filterLongExceptionsLength; } /** * @return {@code true} to strip out illegal signatures from classes. */ @Nonnull public ObservableBoolean getFilterSignatures() { return filterSignatures; } /** * @return {@code true} to strip out synthetic/bridge modifiers from classes, fields, and methods. */ @Nonnull public ObservableBoolean getFilterSynthetics() { return filterSynthetics; } /** * @return {@code true} to filter out any non-ascii referenced name. */ @Nonnull public ObservableBoolean getFilterNonAsciiNames() { return filterNonAsciiNames; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/JvmDecompiler.java ================================================ package software.coley.recaf.services.decompile; import jakarta.annotation.Nonnull; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.decompile.filter.JvmBytecodeFilter; import software.coley.recaf.workspace.model.Workspace; /** * Outline for decompilers targeting {@link JvmClassInfo}. * * @author Matt Coley */ public interface JvmDecompiler extends Decompiler { /** * Adds a filter which operates on the bytecode of classes before passing it along to the decompiler. * * @param filter * Filter to add. * * @return {@code true} on successful addition. * {@code false} if the filter has already been added. */ boolean addJvmBytecodeFilter(@Nonnull JvmBytecodeFilter filter); // TODO: Make config for common defaults (debug stripping, virtual mapping?) /** * Removes a filter which operates on the bytecode of classes before passing it along to the decompiler. * * @param filter * Filter to remove. * * @return {@code true} on successful removal. * {@code false} if the filter was not already registered. */ boolean removeJvmBytecodeFilter(@Nonnull JvmBytecodeFilter filter); /** * @param workspace * Workspace to pull data from. * @param classInfo * Class to decompile. * * @return Decompilation result. */ @Nonnull DecompileResult decompile(@Nonnull Workspace workspace, @Nonnull JvmClassInfo classInfo); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/NoopAndroidDecompiler.java ================================================ package software.coley.recaf.services.decompile; import jakarta.annotation.Nonnull; import software.coley.recaf.info.AndroidClassInfo; import software.coley.recaf.workspace.model.Workspace; /** * No-op decompiler for {@link JvmDecompiler} * * @author Matt Coley */ public class NoopAndroidDecompiler extends AbstractAndroidDecompiler { private static final NoopAndroidDecompiler INSTANCE = new NoopAndroidDecompiler(); private NoopAndroidDecompiler() { super("no-op-android", "1.0.0", new NoopDecompilerConfig()); } /** * @return Singleton instance. */ public static NoopAndroidDecompiler getInstance() { return INSTANCE; } @Override public DecompileResult decompile(@Nonnull Workspace workspace, @Nonnull AndroidClassInfo classInfo) { return new DecompileResult(getConfig().getHash()); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/NoopDecompilerConfig.java ================================================ package software.coley.recaf.services.decompile; import software.coley.recaf.util.ExcludeFromJacocoGeneratedReport; /** * Dummy config for {@link NoopJvmDecompiler} and {@link NoopAndroidDecompiler}. * * @author Matt Coley */ @ExcludeFromJacocoGeneratedReport(justification = "Config POJO") public class NoopDecompilerConfig extends BaseDecompilerConfig implements DecompilerConfig { /** * New dummy config. */ public NoopDecompilerConfig() { super("noop"); } @Override public int getHash() { return 0; } @Override public void setHash(int hash) { // no-op } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/NoopJvmDecompiler.java ================================================ package software.coley.recaf.services.decompile; import jakarta.annotation.Nonnull; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.workspace.model.Workspace; /** * No-op decompiler for {@link JvmDecompiler} * * @author Matt Coley */ public class NoopJvmDecompiler extends AbstractJvmDecompiler { private static final NoopJvmDecompiler INSTANCE = new NoopJvmDecompiler(); private NoopJvmDecompiler() { super("no-op-jvm", "1.0.0", new NoopDecompilerConfig()); } /** * @return Singleton instance. */ public static NoopJvmDecompiler getInstance() { return INSTANCE; } @Nonnull @Override protected DecompileResult decompileInternal(@Nonnull Workspace workspace, @Nonnull JvmClassInfo classInfo) { return new DecompileResult(getConfig().getHash()); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/cfr/CfrConfig.java ================================================ package software.coley.recaf.services.decompile.cfr; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.benf.cfr.reader.api.CfrDriver; import org.benf.cfr.reader.util.ClassFileVersion; import org.benf.cfr.reader.util.getopt.OptionsImpl; import org.benf.cfr.reader.util.getopt.PermittedOptionProvider; import software.coley.observables.ObservableInteger; import software.coley.observables.ObservableObject; import software.coley.recaf.config.BasicConfigValue; import software.coley.recaf.services.decompile.BaseDecompilerConfig; import software.coley.recaf.util.ExcludeFromJacocoGeneratedReport; import software.coley.recaf.util.ReflectUtil; import software.coley.recaf.util.StringUtil; import java.lang.reflect.Field; import java.util.HashMap; import java.util.Map; /** * Config for {@link CfrDecompiler} * * @author Matt Coley * @see OptionsImpl CFR options */ @ApplicationScoped @SuppressWarnings("all") // ignore unused refs / typos @ExcludeFromJacocoGeneratedReport(justification = "Config POJO") public class CfrConfig extends BaseDecompilerConfig { private final ObservableObject stringbuffer = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject stringbuilder = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject stringconcat = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject decodeenumswitch = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject sugarenums = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject decodestringswitch = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject previewfeatures = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject sealed = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject switchexpression = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject recordtypes = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject instanceofpattern = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject arrayiter = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject collectioniter = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject tryresources = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject decodelambdas = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject innerclasses = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject forbidmethodscopedclasses = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject forbidanonymousclasses = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject skipbatchinnerclasses = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject hideutf = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject hidelongstrings = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject removeboilerplate = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject removeinnerclasssynthetics = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject relinkconst = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject relinkconststring = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject liftconstructorinit = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject removedeadmethods = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject removebadgenerics = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject sugarasserts = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject sugarboxing = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject sugarretrolambda = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject showversion = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject decodefinally = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject tidymonitors = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject commentmonitors = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject lenient = new ObservableObject<>(BooleanOption.TRUE); private final ObservableObject comments = new ObservableObject<>(BooleanOption.FALSE); private final ObservableObject forcetopsort = new ObservableObject<>(TrooleanOption.DEFAULT); private final ObservableObject forceclassfilever = new ObservableObject<>(null); private final ObservableObject forloopaggcapture = new ObservableObject<>(TrooleanOption.DEFAULT); private final ObservableObject forcetopsortaggress = new ObservableObject<>(TrooleanOption.DEFAULT); private final ObservableObject forcetopsortnopull = new ObservableObject<>(TrooleanOption.DEFAULT); private final ObservableObject forcecondpropagate = new ObservableObject<>(TrooleanOption.DEFAULT); private final ObservableObject reducecondscope = new ObservableObject<>(TrooleanOption.DEFAULT); private final ObservableObject forcereturningifs = new ObservableObject<>(TrooleanOption.DEFAULT); private final ObservableObject ignoreexceptionsalways = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject antiobf = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject obfcontrol = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject obfattr = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject constobf = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject hidebridgemethods = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject ignoreexceptions = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject forceexceptionprune = new ObservableObject<>(TrooleanOption.DEFAULT); private final ObservableObject aexagg = new ObservableObject<>(TrooleanOption.DEFAULT); private final ObservableObject aexagg2 = new ObservableObject<>(TrooleanOption.DEFAULT); private final ObservableObject recovertypeclash = new ObservableObject<>(TrooleanOption.DEFAULT); private final ObservableObject recovertypehints = new ObservableObject<>(TrooleanOption.DEFAULT); private final ObservableObject recover = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject eclipse = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject override = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject showinferrable = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject version = new ObservableObject<>(BooleanOption.FALSE); private final ObservableObject labelledblocks = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject j14classobj = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject hidelangimports = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject renamedupmembers = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableInteger renamesmallmembers = new ObservableInteger(0); private final ObservableObject renameillegalidents = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject renameenumidents = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject removedeadconditionals = new ObservableObject<>(TrooleanOption.DEFAULT); private final ObservableObject aggressivedoextension = new ObservableObject<>(TrooleanOption.DEFAULT); private final ObservableObject aggressiveduff = new ObservableObject<>(TrooleanOption.DEFAULT); private final ObservableInteger aggressivedocopy = new ObservableInteger(0); private final ObservableInteger aggressivesizethreshold = new ObservableInteger(13000); private final ObservableObject staticinitreturn = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject usenametable = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject pullcodecase = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject allowmalformedswitch = new ObservableObject<>(TrooleanOption.DEFAULT); private final ObservableObject elidescala = new ObservableObject<>(BooleanOption.DEFAULT); private final ObservableObject usesignatures = new ObservableObject<>(BooleanOption.DEFAULT); @Inject public CfrConfig() { super("decompiler-cfr" + CONFIG_SUFFIX); // Add values addValue(new BasicConfigValue<>("stringbuffer", BooleanOption.class, stringbuffer)); addValue(new BasicConfigValue<>("stringbuilder", BooleanOption.class, stringbuilder)); addValue(new BasicConfigValue<>("stringconcat", BooleanOption.class, stringconcat)); addValue(new BasicConfigValue<>("decodeenumswitch", BooleanOption.class, decodeenumswitch)); addValue(new BasicConfigValue<>("sugarenums", BooleanOption.class, sugarenums)); addValue(new BasicConfigValue<>("decodestringswitch", BooleanOption.class, decodestringswitch)); addValue(new BasicConfigValue<>("previewfeatures", BooleanOption.class, previewfeatures)); addValue(new BasicConfigValue<>("sealed", BooleanOption.class, sealed)); addValue(new BasicConfigValue<>("switchexpression", BooleanOption.class, switchexpression)); addValue(new BasicConfigValue<>("recordtypes", BooleanOption.class, recordtypes)); addValue(new BasicConfigValue<>("instanceofpattern", BooleanOption.class, instanceofpattern)); addValue(new BasicConfigValue<>("arrayiter", BooleanOption.class, arrayiter)); addValue(new BasicConfigValue<>("collectioniter", BooleanOption.class, collectioniter)); addValue(new BasicConfigValue<>("tryresources", BooleanOption.class, tryresources)); addValue(new BasicConfigValue<>("decodelambdas", BooleanOption.class, decodelambdas)); addValue(new BasicConfigValue<>("innerclasses", BooleanOption.class, innerclasses)); addValue(new BasicConfigValue<>("forbidmethodscopedclasses", BooleanOption.class, forbidmethodscopedclasses)); addValue(new BasicConfigValue<>("forbidanonymousclasses", BooleanOption.class, forbidanonymousclasses)); addValue(new BasicConfigValue<>("skipbatchinnerclasses", BooleanOption.class, skipbatchinnerclasses)); addValue(new BasicConfigValue<>("hideutf", BooleanOption.class, hideutf)); addValue(new BasicConfigValue<>("hidelongstrings", BooleanOption.class, hidelongstrings)); addValue(new BasicConfigValue<>("removeboilerplate", BooleanOption.class, removeboilerplate)); addValue(new BasicConfigValue<>("removeinnerclasssynthetics", BooleanOption.class, removeinnerclasssynthetics)); addValue(new BasicConfigValue<>("relinkconst", BooleanOption.class, relinkconst)); addValue(new BasicConfigValue<>("relinkconststring", BooleanOption.class, relinkconststring)); addValue(new BasicConfigValue<>("liftconstructorinit", BooleanOption.class, liftconstructorinit)); addValue(new BasicConfigValue<>("removedeadmethods", BooleanOption.class, removedeadmethods)); addValue(new BasicConfigValue<>("removebadgenerics", BooleanOption.class, removebadgenerics)); addValue(new BasicConfigValue<>("sugarasserts", BooleanOption.class, sugarasserts)); addValue(new BasicConfigValue<>("sugarboxing", BooleanOption.class, sugarboxing)); addValue(new BasicConfigValue<>("sugarretrolambda", BooleanOption.class, sugarretrolambda)); addValue(new BasicConfigValue<>("showversion", BooleanOption.class, showversion)); addValue(new BasicConfigValue<>("decodefinally", BooleanOption.class, decodefinally)); addValue(new BasicConfigValue<>("tidymonitors", BooleanOption.class, tidymonitors)); addValue(new BasicConfigValue<>("commentmonitors", BooleanOption.class, commentmonitors)); addValue(new BasicConfigValue<>("lenient", BooleanOption.class, lenient)); addValue(new BasicConfigValue<>("comments", BooleanOption.class, comments)); addValue(new BasicConfigValue<>("forcetopsort", TrooleanOption.class, forcetopsort)); addValue(new BasicConfigValue<>("forceclassfilever", ClassFileVersion.class, forceclassfilever)); addValue(new BasicConfigValue<>("forloopaggcapture", TrooleanOption.class, forloopaggcapture)); addValue(new BasicConfigValue<>("forcetopsortaggress", TrooleanOption.class, forcetopsortaggress)); addValue(new BasicConfigValue<>("forcetopsortnopull", TrooleanOption.class, forcetopsortnopull)); addValue(new BasicConfigValue<>("forcecondpropagate", TrooleanOption.class, forcecondpropagate)); addValue(new BasicConfigValue<>("reducecondscope", TrooleanOption.class, reducecondscope)); addValue(new BasicConfigValue<>("forcereturningifs", TrooleanOption.class, forcereturningifs)); addValue(new BasicConfigValue<>("ignoreexceptionsalways", BooleanOption.class, ignoreexceptionsalways)); addValue(new BasicConfigValue<>("antiobf", BooleanOption.class, antiobf)); addValue(new BasicConfigValue<>("obfcontrol", BooleanOption.class, obfcontrol)); addValue(new BasicConfigValue<>("obfattr", BooleanOption.class, obfattr)); addValue(new BasicConfigValue<>("constobf", BooleanOption.class, constobf)); addValue(new BasicConfigValue<>("hidebridgemethods", BooleanOption.class, hidebridgemethods)); addValue(new BasicConfigValue<>("ignoreexceptions", BooleanOption.class, ignoreexceptions)); addValue(new BasicConfigValue<>("forceexceptionprune", TrooleanOption.class, forceexceptionprune)); addValue(new BasicConfigValue<>("aexagg", TrooleanOption.class, aexagg)); addValue(new BasicConfigValue<>("aexagg2", TrooleanOption.class, aexagg2)); addValue(new BasicConfigValue<>("recovertypeclash", TrooleanOption.class, recovertypeclash)); addValue(new BasicConfigValue<>("recovertypehints", TrooleanOption.class, recovertypehints)); addValue(new BasicConfigValue<>("recover", BooleanOption.class, recover)); addValue(new BasicConfigValue<>("eclipse", BooleanOption.class, eclipse)); addValue(new BasicConfigValue<>("override", BooleanOption.class, override)); addValue(new BasicConfigValue<>("showinferrable", BooleanOption.class, showinferrable)); addValue(new BasicConfigValue<>("version", BooleanOption.class, version)); addValue(new BasicConfigValue<>("labelledblocks", BooleanOption.class, labelledblocks)); addValue(new BasicConfigValue<>("j14classobj", BooleanOption.class, j14classobj)); addValue(new BasicConfigValue<>("hidelangimports", BooleanOption.class, hidelangimports)); addValue(new BasicConfigValue<>("renamedupmembers", BooleanOption.class, renamedupmembers)); addValue(new BasicConfigValue<>("renamesmallmembers", int.class, renamesmallmembers)); addValue(new BasicConfigValue<>("renameillegalidents", BooleanOption.class, renameillegalidents)); addValue(new BasicConfigValue<>("renameenumidents", BooleanOption.class, renameenumidents)); addValue(new BasicConfigValue<>("removedeadconditionals", TrooleanOption.class, removedeadconditionals)); addValue(new BasicConfigValue<>("aggressivedoextension", TrooleanOption.class, aggressivedoextension)); addValue(new BasicConfigValue<>("aggressiveduff", TrooleanOption.class, aggressiveduff)); addValue(new BasicConfigValue<>("aggressivedocopy", int.class, aggressivedocopy)); addValue(new BasicConfigValue<>("aggressivesizethreshold", int.class, aggressivesizethreshold)); addValue(new BasicConfigValue<>("staticinitreturn", BooleanOption.class, staticinitreturn)); addValue(new BasicConfigValue<>("usenametable", BooleanOption.class, usenametable)); addValue(new BasicConfigValue<>("pullcodecase", BooleanOption.class, pullcodecase)); addValue(new BasicConfigValue<>("allowmalformedswitch", TrooleanOption.class, allowmalformedswitch)); addValue(new BasicConfigValue<>("elidescala", BooleanOption.class, elidescala)); addValue(new BasicConfigValue<>("usesignatures", BooleanOption.class, usesignatures)); registerConfigValuesHashUpdates(); } /** * Fetch help description from configuration parameter. * * @param name * Parameter/option name. * * @return Help description string, may be {@code null}. */ @Nullable @SuppressWarnings("rawtypes") public static String getOptHelp(String name) { for (Field declaredField : OptionsImpl.class.getDeclaredFields()) { if (PermittedOptionProvider.ArgumentParam.class.isAssignableFrom(declaredField.getType())) { PermittedOptionProvider.ArgumentParam param = ReflectUtil.quietGet(null, declaredField); if (param != null && param.getName().equals(name)) return getOptHelp(param); } } return null; } /** * Fetch help description from configuration parameter. * * @param param * Parameter. * * @return Help description string. */ @Nonnull private static String getOptHelp(PermittedOptionProvider.ArgumentParam param) { try { Field fn = PermittedOptionProvider.ArgumentParam.class.getDeclaredField("help"); fn.setAccessible(true); String value = (String) fn.get(param); if (StringUtil.isNullOrEmpty(value)) value = ""; return value; } catch (ReflectiveOperationException ex) { throw new IllegalStateException("Failed to fetch description from Cfr parameter, did" + " the backend change?"); } } /** * @return CFR compatible string map for {@link CfrDriver.Builder#withOptions(Map)}. */ @Nonnull public Map toMap() { Map map = new HashMap<>(); getValues().forEach((name, config) -> { Class type = config.getType(); if (type == BooleanOption.class) { // Boolean option, values should be 'true' or 'false' BooleanOption value = (BooleanOption) config.getValue(); if (value != BooleanOption.DEFAULT) { String booleanName = value.name().toLowerCase(); map.put(name, booleanName); } } else if (type == TrooleanOption.class) { // Troolean option, values should be 'true' or 'false' for respective cases. // The 'neither' option is selected when null is passed. TrooleanOption value = (TrooleanOption) config.getValue(); if (value == TrooleanOption.NEITHER) { map.put(name, null); } else if (value != TrooleanOption.DEFAULT) { String booleanName = value.name().toLowerCase(); map.put(name, booleanName); } } else if (type == Integer.class) { // Integer option, value is just int Integer value = (Integer) config.getValue(); if (value != null) { map.put(name, value.toString()); } } else if (type == ClassFileVersion.class) { // Class version option, values represented as 'MAJOR.MINOR'. // Java 8 would be '52.0' ClassFileVersion value = (ClassFileVersion) config.getValue(); if (value != null) { // The 'toString()' handles the format for us. map.put(name, value.toString()); } } }); return map; } @Nonnull public ObservableObject getStringbuffer() { return stringbuffer; } @Nonnull public ObservableObject getStringbuilder() { return stringbuilder; } @Nonnull public ObservableObject getStringconcat() { return stringconcat; } @Nonnull public ObservableObject getDecodeenumswitch() { return decodeenumswitch; } @Nonnull public ObservableObject getSugarenums() { return sugarenums; } @Nonnull public ObservableObject getDecodestringswitch() { return decodestringswitch; } @Nonnull public ObservableObject getPreviewfeatures() { return previewfeatures; } @Nonnull public ObservableObject getSealed() { return sealed; } @Nonnull public ObservableObject getSwitchexpression() { return switchexpression; } @Nonnull public ObservableObject getRecordtypes() { return recordtypes; } @Nonnull public ObservableObject getInstanceofpattern() { return instanceofpattern; } @Nonnull public ObservableObject getArrayiter() { return arrayiter; } @Nonnull public ObservableObject getCollectioniter() { return collectioniter; } @Nonnull public ObservableObject getTryresources() { return tryresources; } @Nonnull public ObservableObject getDecodelambdas() { return decodelambdas; } @Nonnull public ObservableObject getInnerclasses() { return innerclasses; } @Nonnull public ObservableObject getForbidmethodscopedclasses() { return forbidmethodscopedclasses; } @Nonnull public ObservableObject getForbidanonymousclasses() { return forbidanonymousclasses; } @Nonnull public ObservableObject getSkipbatchinnerclasses() { return skipbatchinnerclasses; } @Nonnull public ObservableObject getHideutf() { return hideutf; } @Nonnull public ObservableObject getHidelongstrings() { return hidelongstrings; } @Nonnull public ObservableObject getRemoveboilerplate() { return removeboilerplate; } @Nonnull public ObservableObject getRemoveinnerclasssynthetics() { return removeinnerclasssynthetics; } @Nonnull public ObservableObject getRelinkconst() { return relinkconst; } @Nonnull public ObservableObject getRelinkconststring() { return relinkconststring; } @Nonnull public ObservableObject getLiftconstructorinit() { return liftconstructorinit; } @Nonnull public ObservableObject getRemovedeadmethods() { return removedeadmethods; } @Nonnull public ObservableObject getRemovebadgenerics() { return removebadgenerics; } @Nonnull public ObservableObject getSugarasserts() { return sugarasserts; } @Nonnull public ObservableObject getSugarboxing() { return sugarboxing; } @Nonnull public ObservableObject getSugarretrolambda() { return sugarretrolambda; } @Nonnull public ObservableObject getShowversion() { return showversion; } @Nonnull public ObservableObject getDecodefinally() { return decodefinally; } @Nonnull public ObservableObject getTidymonitors() { return tidymonitors; } @Nonnull public ObservableObject getCommentmonitors() { return commentmonitors; } @Nonnull public ObservableObject getLenient() { return lenient; } @Nonnull public ObservableObject getComments() { return comments; } @Nonnull public ObservableObject getForcetopsort() { return forcetopsort; } @Nonnull public ObservableObject getForceclassfilever() { return forceclassfilever; } @Nonnull public ObservableObject getForloopaggcapture() { return forloopaggcapture; } @Nonnull public ObservableObject getForcetopsortaggress() { return forcetopsortaggress; } @Nonnull public ObservableObject getForcetopsortnopull() { return forcetopsortnopull; } @Nonnull public ObservableObject getForcecondpropagate() { return forcecondpropagate; } @Nonnull public ObservableObject getReducecondscope() { return reducecondscope; } @Nonnull public ObservableObject getForcereturningifs() { return forcereturningifs; } @Nonnull public ObservableObject getIgnoreexceptionsalways() { return ignoreexceptionsalways; } @Nonnull public ObservableObject getAntiobf() { return antiobf; } @Nonnull public ObservableObject getObfcontrol() { return obfcontrol; } @Nonnull public ObservableObject getObfattr() { return obfattr; } @Nonnull public ObservableObject getConstobf() { return constobf; } @Nonnull public ObservableObject getHidebridgemethods() { return hidebridgemethods; } @Nonnull public ObservableObject getIgnoreexceptions() { return ignoreexceptions; } @Nonnull public ObservableObject getForceexceptionprune() { return forceexceptionprune; } @Nonnull public ObservableObject getAexagg() { return aexagg; } @Nonnull public ObservableObject getAexagg2() { return aexagg2; } @Nonnull public ObservableObject getRecovertypeclash() { return recovertypeclash; } @Nonnull public ObservableObject getRecovertypehints() { return recovertypehints; } @Nonnull public ObservableObject getRecover() { return recover; } @Nonnull public ObservableObject getEclipse() { return eclipse; } @Nonnull public ObservableObject getOverride() { return override; } @Nonnull public ObservableObject getShowinferrable() { return showinferrable; } @Nonnull public ObservableObject getVersion() { return version; } @Nonnull public ObservableObject getLabelledblocks() { return labelledblocks; } @Nonnull public ObservableObject getJ14classobj() { return j14classobj; } @Nonnull public ObservableObject getHidelangimports() { return hidelangimports; } @Nonnull public ObservableObject getRenamedupmembers() { return renamedupmembers; } @Nonnull public ObservableInteger getRenamesmallmembers() { return renamesmallmembers; } @Nonnull public ObservableObject getRenameillegalidents() { return renameillegalidents; } @Nonnull public ObservableObject getRenameenumidents() { return renameenumidents; } @Nonnull public ObservableObject getRemovedeadconditionals() { return removedeadconditionals; } @Nonnull public ObservableObject getAggressivedoextension() { return aggressivedoextension; } @Nonnull public ObservableObject getAggressiveduff() { return aggressiveduff; } @Nonnull public ObservableInteger getAggressivedocopy() { return aggressivedocopy; } @Nonnull public ObservableInteger getAggressivesizethreshold() { return aggressivesizethreshold; } @Nonnull public ObservableObject getStaticinitreturn() { return staticinitreturn; } @Nonnull public ObservableObject getUsenametable() { return usenametable; } @Nonnull public ObservableObject getPullcodecase() { return pullcodecase; } @Nonnull public ObservableObject getAllowmalformedswitch() { return allowmalformedswitch; } @Nonnull public ObservableObject getElidescala() { return elidescala; } @Nonnull public ObservableObject getUsesignatures() { return usesignatures; } /** * Wrapper for CFR boolean values. */ public enum BooleanOption { DEFAULT, TRUE, FALSE } /** * Wrapper for CFR troolean values. */ public enum TrooleanOption { DEFAULT, NEITHER, TRUE, FALSE } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/cfr/CfrDecompiler.java ================================================ package software.coley.recaf.services.decompile.cfr; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.benf.cfr.reader.api.CfrDriver; import org.benf.cfr.reader.bytecode.analysis.structured.statement.StructuredComment; import org.benf.cfr.reader.util.CfrVersionInfo; import org.benf.cfr.reader.util.DecompilerComment; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.decompile.AbstractJvmDecompiler; import software.coley.recaf.services.decompile.DecompileResult; import software.coley.recaf.services.workspace.WorkspaceManager; import software.coley.recaf.util.ReflectUtil; import software.coley.recaf.workspace.model.Workspace; import java.lang.reflect.Field; import java.util.Collections; import java.util.Objects; /** * CFR decompiler implementation. * * @author Matt Coley */ @ApplicationScoped public class CfrDecompiler extends AbstractJvmDecompiler { public static final String NAME = "CFR"; private final CfrConfig config; /** * New CFR decompiler instance. * * @param workspaceManager * Workspace manager. * @param config * Config instance. */ @Inject public CfrDecompiler(@Nonnull WorkspaceManager workspaceManager, @Nonnull CfrConfig config) { super(NAME, CfrVersionInfo.VERSION, config); this.config = config; workspaceManager.addWorkspaceCloseListener(workspace -> cleanup()); // TODO: Update CFR when https://github.com/leibnitz27/cfr/issues/361 is fixed } @Nonnull @Override protected DecompileResult decompileInternal(@Nonnull Workspace workspace, @Nonnull JvmClassInfo classInfo) { String name = classInfo.getName(); byte[] bytecode = classInfo.getBytecode(); ClassSource source = new ClassSource(workspace, name, bytecode); SinkFactoryImpl sink = new SinkFactoryImpl(); CfrDriver driver = new CfrDriver.Builder() .withClassFileSource(source) .withOutputSink(sink) .withOptions(config.toMap()) .build(); driver.analyse(Collections.singletonList(name)); String decompile = sink.getDecompilation(); int configHash = getConfig().getHash(); if (decompile == null) { Throwable exception = Objects.requireNonNullElseGet(sink.getException(), () -> { Throwable err = new IllegalStateException("CFR did not provide any output:\n- No decompilation output\n- No error message / trace"); err.setStackTrace(new StackTraceElement[0]); return err; }); return new DecompileResult(exception, configHash); } return new DecompileResult(filter(decompile), configHash); } @Nonnull @Override public CfrConfig getConfig() { return (CfrConfig) super.getConfig(); } private static void cleanup() { // Some CFR code through a chain of events assigns a container to this constant, and it // holds a reference to our ClassSource which has the workspace data in it. // That causes a memory leak, so we clear the container here after each decomp. StructuredComment.EMPTY_COMMENT.setContainer(null); } private static String filter(String decompile) { // CFR emits a 'Decompiled with CFR' header, which is annoying, so we'll remove that. int commentStart = decompile.indexOf("/*\n"); int commentEnd = decompile.indexOf(" */\n"); if (commentStart >= 0 && commentEnd > commentStart) decompile = decompile.substring(0, commentStart) + decompile.substring(commentEnd + 4); return decompile; } static { try { // Rewrite CFR comments to not say "use --option" since this is not a command line context. Field field = ReflectUtil.getDeclaredField(DecompilerComment.class, "comment"); ReflectUtil.quietSet(DecompilerComment.RENAME_MEMBERS, field, "Duplicate member names detected"); ReflectUtil.quietSet(DecompilerComment.ILLEGAL_IDENTIFIERS, field, "Illegal identifiers detected"); ReflectUtil.quietSet(DecompilerComment.MALFORMED_SWITCH, field, "Recovered potentially malformed switches"); } catch (Exception ex) { ex.printStackTrace(); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/cfr/ClassSource.java ================================================ package software.coley.recaf.services.decompile.cfr; import jakarta.annotation.Nonnull; import org.benf.cfr.reader.api.ClassFileSource; import org.benf.cfr.reader.bytecode.analysis.parse.utils.Pair; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.workspace.model.Workspace; import java.util.Collection; import java.util.Collections; /** * CFR class source. Provides access to workspace clases. * * @author Matt Coley */ public class ClassSource implements ClassFileSource { private final Workspace workspace; private final String targetClassName; private final byte[] targetClassBytecode; /** * Constructs a CFR class source. * * @param workspace * Workspace to pull classes from. * @param targetClassName * Name to override. * @param targetClassBytecode * Bytecode to override. */ public ClassSource(@Nonnull Workspace workspace, @Nonnull String targetClassName, @Nonnull byte[] targetClassBytecode) { this.workspace = workspace; this.targetClassName = targetClassName; this.targetClassBytecode = targetClassBytecode; } @Override public void informAnalysisRelativePathDetail(String usePath, String specPath) { } @Override public Collection addJar(String jarPath) { return Collections.emptySet(); } @Override public String getPossiblyRenamedPath(String path) { return path; } @Override public Pair getClassFileContent(String inputPath) { String className = inputPath.substring(0, inputPath.indexOf(".class")); byte[] code; if (className.equals(targetClassName)) { code = targetClassBytecode; } else { ClassPathNode result = workspace.findClass(className); code = result == null ? null : result.getValue().asJvmClass().getBytecode(); } return new Pair<>(code, inputPath); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/cfr/SinkFactoryImpl.java ================================================ package software.coley.recaf.services.decompile.cfr; import jakarta.annotation.Nullable; import org.benf.cfr.reader.api.OutputSinkFactory; import org.slf4j.Logger; import software.coley.recaf.analytics.logging.Logging; import java.util.Arrays; import java.util.Collection; import java.util.List; /** * Cfr logging/output sinker. * * @author Matt */ public class SinkFactoryImpl implements OutputSinkFactory { private static final Logger logger = Logging.get(SinkFactoryImpl.class); private Throwable exception; private String decompile; @Override public List getSupportedSinks(SinkType sinkType, Collection collection) { return Arrays.asList(SinkClass.values()); } @Override public Sink getSink(SinkType sinkType, SinkClass sinkClass) { return switch (sinkType) { case JAVA -> this::setDecompilation; case EXCEPTION -> this::handleException; default -> t -> { }; }; } private void handleException(@Nullable T value) { if (value instanceof Throwable) { logger.error("CFR Error: {}", value); exception = (Throwable) value; } else { logger.error("CFR encountered an error but provided no additional information"); } } private void setDecompilation(T value) { decompile = value.toString(); } /** * @return Decompiled class content. */ @Nullable public String getDecompilation() { return decompile; } /** * @return Failure reason. */ @Nullable public Throwable getException() { return exception; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/fallback/FallbackConfig.java ================================================ package software.coley.recaf.services.decompile.fallback; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.services.decompile.BaseDecompilerConfig; import software.coley.recaf.util.ExcludeFromJacocoGeneratedReport; /** * Config for {@link FallbackDecompiler} * * @author Matt Coley */ @ApplicationScoped @ExcludeFromJacocoGeneratedReport(justification = "Config POJO") public class FallbackConfig extends BaseDecompilerConfig { @Inject public FallbackConfig() { super("decompiler-fallback" + CONFIG_SUFFIX); registerConfigValuesHashUpdates(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/fallback/FallbackDecompiler.java ================================================ package software.coley.recaf.services.decompile.fallback; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.decompile.AbstractJvmDecompiler; import software.coley.recaf.services.decompile.DecompileResult; import software.coley.recaf.services.decompile.fallback.print.ClassPrinter; import software.coley.recaf.services.text.TextFormatConfig; import software.coley.recaf.workspace.model.Workspace; /** * Fallback decompiler implementation. * * @author Matt Coley */ @ApplicationScoped public class FallbackDecompiler extends AbstractJvmDecompiler { public static final String NAME = "Fallback"; private static final String VERSION = "1.0.0"; private final TextFormatConfig formatConfig; /** * New Procyon decompiler instance. * * @param config * Config instance. */ @Inject public FallbackDecompiler(@Nonnull FallbackConfig config, @Nonnull TextFormatConfig formatConfig) { super(NAME, VERSION, config); this.formatConfig = formatConfig; } @Nonnull @Override protected DecompileResult decompileInternal(@Nonnull Workspace workspace, @Nonnull JvmClassInfo classInfo) { String decompile = new ClassPrinter(formatConfig, classInfo).print(); int configHash = getConfig().getHash(); return new DecompileResult(decompile, configHash); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/fallback/print/ClassPrinter.java ================================================ package software.coley.recaf.services.decompile.fallback.print; import jakarta.annotation.Nonnull; import org.objectweb.asm.Type; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.annotation.AnnotationElement; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.services.text.TextFormatConfig; import software.coley.recaf.util.AccessFlag; import software.coley.recaf.util.StringUtil; import java.util.*; import java.util.stream.Collectors; /** * Basic class printer. * * @author Matt Coley */ public class ClassPrinter { private final TextFormatConfig format; private final JvmClassInfo classInfo; /** * @param format * Format config. * @param classInfo * Class to print. */ public ClassPrinter(@Nonnull TextFormatConfig format, @Nonnull JvmClassInfo classInfo) { this.format = format; this.classInfo = classInfo; } /** * @return Formatted class output. */ @Nonnull public String print() { Printer out = new Printer(); appendPackage(out); appendImports(out); appendDeclaration(out); appendMembers(out); return out.toString(); } /** * Appends the package name to the output. * * @param out * Printer to write to. */ private void appendPackage(@Nonnull Printer out) { String className = classInfo.getName(); if (className.contains("/")) { String packageName = format.filterEscape(className.substring(0, className.lastIndexOf('/'))); out.appendLine("package " + packageName.replace('/', '.') + ";"); out.newLine(); } } /** * Appends each imported class to the output. * * @param out * Printer to write to. */ private void appendImports(@Nonnull Printer out) { String lastRootPackage = null; NavigableSet referencedClasses = new TreeSet<>(classInfo.getReferencedClasses()); boolean hasImports = false; for (String referencedClass : referencedClasses) { // Skip classes in the default package. if (!referencedClass.contains("/")) continue; // Skip core classes that are implicitly imported. if (referencedClass.startsWith("java/lang/")) continue; // Skip self. if (referencedClass.equals(classInfo.getName())) continue; // Break root package imports up for clarity. For example: // - com.* // - org.* // Between these two import groups will be a blank line. String rootPackage = referencedClass.substring(0, referencedClass.indexOf('/')); if (lastRootPackage == null) lastRootPackage = rootPackage; if (!rootPackage.equals(lastRootPackage)) { out.newLine(); lastRootPackage = rootPackage; } // Add import out.appendLine("import " + format.filterEscape(referencedClass.replace('/', '.')) + ";"); hasImports = true; // TODO: Import names aren't always correct since '$' should also be escaped when it represents the separation of // an outer and inner class. Since we have workspace and runtime access we 'should' check this // and attempt to make more accurate output } if (hasImports) out.newLine(); } /** * Appends the class declaration to the output. * * @param out * Printer to write to. */ private void appendDeclaration(@Nonnull Printer out) { appendDeclarationAnnotations(out); if (classInfo.hasEnumModifier()) { appendEnumDeclaration(out); } else if (classInfo.hasAnnotationModifier()) { appendAnnotationDeclaration(out); } else if (classInfo.hasInterfaceModifier()) { appendInterfaceDeclaration(out); } else { appendStandardDeclaration(out); } } /** * Appends class annotations to the output. * * @param out * Printer to write to. */ private void appendDeclarationAnnotations(@Nonnull Printer out) { String annotations = PrintUtils.annotationsToString(format, classInfo); if (!annotations.isBlank()) out.appendMultiLine(annotations); } /** * Appends the enum formatted declaration to the output. * * @param out * Printer to write to. */ private void appendEnumDeclaration(@Nonnull Printer out) { int acc = classInfo.getAccess(); // Get flag-set and remove 'enum' and 'final'. // We will add 'enum' ourselves, and 'final' is redundant. Set flagSet = AccessFlag.getApplicableFlags(AccessFlag.Type.CLASS, acc); flagSet.remove(AccessFlag.ACC_ENUM); flagSet.remove(AccessFlag.ACC_FINAL); String decFlagsString = AccessFlag.sortAndToString(AccessFlag.Type.CLASS, flagSet); StringBuilder sb = new StringBuilder(); if (decFlagsString.isBlank()) { sb.append("enum "); } else { sb.append(decFlagsString).append(" enum "); } sb.append(format.filter(classInfo.getName())); String superName = classInfo.getSuperName(); // Should normally extend enum. Technically bytecode allows for other types if those at runtime then // inherit from Enum. if (superName != null && !superName.equals("java/lang/Enum")) { sb.append(" extends ").append(format.filter(superName)); } if (!classInfo.getInterfaces().isEmpty()) { sb.append(" implements "); String interfaces = classInfo.getInterfaces().stream() .map(format::filter) .collect(Collectors.joining(", ")); sb.append(interfaces); } out.appendLine(sb.toString()); } /** * Appends the annotation formatted declaration to the output. * * @param out * Printer to write to. */ private void appendAnnotationDeclaration(@Nonnull Printer out) { int acc = classInfo.getAccess(); // Get flag-set and remove 'interface' and 'abstract'. // We will add 'interface' ourselves, and 'abstract' is redundant. Set flagSet = AccessFlag.getApplicableFlags(AccessFlag.Type.CLASS, acc); flagSet.remove(AccessFlag.ACC_ANNOTATION); flagSet.remove(AccessFlag.ACC_INTERFACE); flagSet.remove(AccessFlag.ACC_ABSTRACT); String decFlagsString = AccessFlag.sortAndToString(AccessFlag.Type.CLASS, flagSet); StringBuilder sb = new StringBuilder(); if (decFlagsString.isBlank()) { sb.append("@interface "); } else { sb.append(decFlagsString).append(" @interface "); } sb.append(format.filter(classInfo.getName())); out.appendLine(sb.toString()); } /** * Appends the interface formatted declaration to the output. * * @param out * Printer to write to. */ private void appendInterfaceDeclaration(@Nonnull Printer out) { int acc = classInfo.getAccess(); // Get flag-set and remove 'interface' and 'abstract'. // We will add 'interface' ourselves, and 'abstract' is redundant. Set flagSet = AccessFlag.getApplicableFlags(AccessFlag.Type.CLASS, acc); flagSet.remove(AccessFlag.ACC_INTERFACE); flagSet.remove(AccessFlag.ACC_ABSTRACT); String decFlagsString = AccessFlag.sortAndToString(AccessFlag.Type.CLASS, flagSet); StringBuilder sb = new StringBuilder(); if (decFlagsString.isBlank()) { sb.append("interface "); } else { sb.append(decFlagsString) .append(" interface "); } sb.append(format.filter(classInfo.getName())); if (!classInfo.getInterfaces().isEmpty()) { // Interfaces use 'extends' rather than 'implements'. sb.append(" extends "); String interfaces = classInfo.getInterfaces().stream() .map(format::filter) .collect(Collectors.joining(", ")); sb.append(interfaces); } out.appendLine(sb.toString()); } /** * Appends the class formatted declaration to the output. * * @param out * Printer to write to. */ private void appendStandardDeclaration(@Nonnull Printer out) { int acc = classInfo.getAccess(); String decFlagsString = AccessFlag.sortAndToString(AccessFlag.Type.CLASS, acc); StringBuilder sb = new StringBuilder(); if (decFlagsString.isBlank()) { sb.append("class "); } else { sb.append(decFlagsString).append(" class "); } sb.append(format.filter(classInfo.getName())); String superName = classInfo.getSuperName(); if (superName != null && !superName.equals("java/lang/Object")) { sb.append(" extends ").append(format.filter(superName)); } if (!classInfo.getInterfaces().isEmpty()) { sb.append(" implements "); String interfaces = classInfo.getInterfaces().stream() .map(format::filter) .collect(Collectors.joining(", ")); sb.append(interfaces); } out.appendLine(sb.toString()); } /** * Appends the class body (members). * * @param out * Printer to write to. */ private void appendMembers(@Nonnull Printer out) { out.appendLine("{"); if (!classInfo.getFields().isEmpty()) { Printer fieldPrinter = new Printer(); fieldPrinter.setIndent(" "); if (classInfo.hasEnumModifier()) { appendEnumFieldMembers(fieldPrinter); } else { appendFieldMembers(fieldPrinter); } out.appendMultiLine(fieldPrinter.toString()); out.appendLine(""); } if (!classInfo.getMethods().isEmpty()) { Printer methodPrinter = new Printer(); methodPrinter.setIndent(" "); // Some method types we'll want to handle a bit differently. // Split them up: // - Regular methods // - The static initializer // - Constructors List methods = new ArrayList<>(classInfo.getMethods()); MethodMember staticInitializer = classInfo.getDeclaredMethod("", "()V"); List constructors = classInfo.methodStream() .filter(m -> m.getName().equals("")) .toList(); methods.remove(staticInitializer); methods.removeAll(constructors); // We'll place the static initializer first regardless of where its defined order-wise. if (staticInitializer != null) { appendStaticInitializer(methodPrinter, staticInitializer); methodPrinter.newLine(); } // Then the constructors. for (MethodMember constructor : constructors) { appendConstructor(methodPrinter, constructor); methodPrinter.newLine(); } // Then the rest of the methods, in whatever order they're defined in. for (MethodMember method : methods) { appendMethod(methodPrinter, method); methodPrinter.newLine(); } // Append them all to the output. out.appendMultiLine(methodPrinter.toString()); } out.appendLine("}"); } /** * Appends all fields in the class. * * @param out * Printer to write to. * * @see #appendEnumFieldMembers(Printer) To be used when the current class is an enum. */ private void appendFieldMembers(@Nonnull Printer out) { for (FieldMember field : classInfo.getFields()) { appendField(out, field); } } /** * Appends all enum constants, then other fields in the class. * * @param out * Printer to write to. * * @see #appendEnumFieldMembers(Printer) To be used when the current class is not an enum. */ private void appendEnumFieldMembers(@Nonnull Printer out) { // Filter out enum constants List enumConstFields = new ArrayList<>(); List otherFields = new ArrayList<>(); for (FieldMember field : classInfo.getFields()) { if (isEnumConst(field)) { enumConstFields.add(field); } else { otherFields.add(field); } } // Print enum constants first. for (int i = 0; i < enumConstFields.size(); i++) { String suffix = i == enumConstFields.size() - 1 ? ";\n" : ", "; FieldMember enumConst = enumConstFields.get(i); StringBuilder sb = new StringBuilder(); String annotations = PrintUtils.annotationsToString(format, enumConst); if (!annotations.isBlank()) sb.append(annotations).append('\n'); sb.append(enumConst.getName()).append(suffix); out.appendMultiLine(sb.toString()); } out.newLine(); // And then the rest of the fields for (FieldMember field : otherFields) { appendField(out, field); } } /** * Appends the given field. * * @param out * Printer to write to. * @param field * Field to write to the given printer. */ private void appendField(@Nonnull Printer out, @Nonnull FieldMember field) { StringBuilder declaration = new StringBuilder(); // Append annotations to builder. String annotations = PrintUtils.annotationsToString(format, field); if (!annotations.isBlank()) declaration.append(annotations).append('\n'); // Append flags to builder. Collection flags = AccessFlag.getApplicableFlags(AccessFlag.Type.FIELD, field.getAccess()); flags.remove(AccessFlag.ACC_ENUM); // We don't want to print 'enum' as a flag flags = AccessFlag.sort(AccessFlag.Type.FIELD, flags); if (!flags.isEmpty()) declaration.append(AccessFlag.toString(flags)).append(' '); // Append type + name to builder. Type type = Type.getType(field.getDescriptor()); String typeName = format.filter(type.getClassName()); if (typeName.contains(".")) typeName = typeName.substring(typeName.lastIndexOf(".") + 1); declaration.append(typeName).append(' ').append(format.filter(field.getName())); // Append value to builder. Object value = field.getDefaultValue(); if (value != null) { switch (value) { case String s -> value = "\"" + format.filter(s) + "\""; case Float v -> value = value + "F"; case Long l -> value = value + "L"; default -> { // No change } } declaration.append(" = ").append(value); } // Cap it off. declaration.append(';'); out.appendMultiLine(declaration.toString()); } /** * @param field * Field to check. * * @return {@code true} when it is an enum constant of the {@link #classInfo current class}. */ private boolean isEnumConst(@Nonnull FieldMember field) { String descriptor = field.getDescriptor(); if (descriptor.length() < 3) return false; String type = descriptor.substring(1, descriptor.length() - 1); // Must be same type as declaring class. if (!type.equals(classInfo.getName())) return false; // Must have enum const flags return AccessFlag.hasAll(field.getAccess(), AccessFlag.ACC_STATIC, AccessFlag.ACC_FINAL); } /** * Appends the given static initializer method. * * @param out * Printer to write to. * @param method * Static initializer method. */ private void appendStaticInitializer(@Nonnull Printer out, @Nonnull MethodMember method) { MethodPrinter clinitPrinter = new MethodPrinter(format, classInfo, method) { @Override protected void buildDeclarationFlags(@Nonnull StringBuilder sb) { // Force only printing the modifier 'static' even if other flags are present sb.append("static"); } @Override protected void buildDeclarationReturnType(@Nonnull StringBuilder sb) { // no-op } @Override protected void buildDeclarationName(@Nonnull StringBuilder sb) { // no-op } @Override protected void buildDeclarationArgs(@Nonnull StringBuilder sb) { // no-op } @Override protected void buildDeclarationThrows(@Nonnull StringBuilder sb) { // no-op } }; out.appendMultiLine(clinitPrinter.print()); } /** * Appends the given constructor method. * * @param out * Printer to write to. * @param method * Constructor method. */ private void appendConstructor(@Nonnull Printer out, @Nonnull MethodMember method) { MethodPrinter constructorPrinter = new MethodPrinter(format, classInfo, method) { @Override protected void buildDeclarationReturnType(@Nonnull StringBuilder sb) { // no-op } @Override protected void buildDeclarationName(@Nonnull StringBuilder sb) { // The name is always the class name sb.append(format.filterEscape(StringUtil.shortenPath(classInfo.getName()))); } }; out.appendMultiLine(constructorPrinter.print()); } /** * Appends the given method. * * @param out * Printer to write to. * @param method * Regular method. */ private void appendMethod(@Nonnull Printer out, @Nonnull MethodMember method) { if (classInfo.hasAnnotationModifier()) { MethodPrinter constructorPrinter = new MethodPrinter(format, classInfo, method) { @Override protected void buildDeclarationFlags(@Nonnull StringBuilder sb) { // no-op since all methods are 'public abstract' per interface contract (with additional restrictions) } @Override protected void appendAbstractBody(@Nonnull StringBuilder sb) { AnnotationElement annotationDefault = method.getAnnotationDefault(); if (annotationDefault != null) { sb.append(" default ").append(PrintUtils.elementToString(format, annotationDefault)).append(";"); } else { sb.append(";"); } } }; out.appendMultiLine(constructorPrinter.print()); } else if (classInfo.hasInterfaceModifier()) { MethodPrinter constructorPrinter = new MethodPrinter(format, classInfo, method) { @Override protected void buildDeclarationFlags(@Nonnull StringBuilder sb) { Collection flags = AccessFlag.getApplicableFlags(AccessFlag.Type.METHOD, method.getAccess()); flags = AccessFlag.sort(AccessFlag.Type.METHOD, flags); flags.remove(AccessFlag.ACC_PUBLIC); flags.remove(AccessFlag.ACC_ABSTRACT); boolean isAbstract = AccessFlag.isAbstract(method.getAccess()); if (!flags.isEmpty()) { String flagsStr = AccessFlag.toString(flags); if (!isAbstract) sb.append("default "); sb.append(flagsStr).append(' '); } else if (!isAbstract) sb.append("default "); } }; out.appendMultiLine(constructorPrinter.print()); } else { out.appendMultiLine(new MethodPrinter(format, classInfo, method).print()); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/fallback/print/MethodPrinter.java ================================================ package software.coley.recaf.services.decompile.fallback.print; import jakarta.annotation.Nonnull; import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.MethodVisitor; import org.objectweb.asm.Type; import org.objectweb.asm.util.Textifier; import org.objectweb.asm.util.TraceMethodVisitor; import software.coley.recaf.RecafConstants; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.services.text.TextFormatConfig; import software.coley.recaf.util.AccessFlag; import software.coley.recaf.util.StringUtil; import software.coley.recaf.util.visitors.MemberFilteringVisitor; import java.io.ByteArrayOutputStream; import java.io.PrintWriter; import java.nio.charset.StandardCharsets; import java.util.Collection; import java.util.List; import java.util.stream.Collectors; /** * Utility for printing method bodies. * * @author Matt Coley */ public class MethodPrinter { private final TextFormatConfig format; private final JvmClassInfo classInfo; private final MethodMember method; /** * @param format * Format config. * @param classInfo * Class containing the method. * @param method * Method to print. */ public MethodPrinter(@Nonnull TextFormatConfig format, @Nonnull JvmClassInfo classInfo, @Nonnull MethodMember method) { this.format = format; this.classInfo = classInfo; this.method = method; } /** * @return Method string representation. */ @Nonnull public String print() { StringBuilder sb = new StringBuilder(); appendAnnotations(sb); appendDeclaration(sb); if (AccessFlag.isNative(method.getAccess()) || AccessFlag.isAbstract(method.getAccess())) { appendAbstractBody(sb); } else { appendBody(sb); } return sb.toString(); } /** * Appends annotations on the method declaration to the printer. * * @param sb * Builder to add to. */ protected void appendAnnotations(@Nonnull StringBuilder sb) { String annotations = PrintUtils.annotationsToString(format, method); if (!annotations.isBlank()) sb.append(annotations).append('\n'); } /** * Appends the method declaration to the printer. *
    *
  1. {@link #buildDeclarationFlags(StringBuilder)}
  2. *
  3. {@link #buildDeclarationReturnType(StringBuilder)}
  4. *
  5. {@link #buildDeclarationName(StringBuilder)}
  6. *
  7. {@link #buildDeclarationArgs(StringBuilder)}
  8. *
* * @param sb * Builder to add to. */ protected void appendDeclaration(@Nonnull StringBuilder sb) { buildDeclarationFlags(sb); buildDeclarationReturnType(sb); buildDeclarationName(sb); buildDeclarationArgs(sb); buildDeclarationThrows(sb); } /** * Appends the following pattern to the builder: *
	 * public static abstract...
	 * 
* * @param sb * Builder to add to. */ protected void buildDeclarationFlags(@Nonnull StringBuilder sb) { Collection flags = AccessFlag.getApplicableFlags(AccessFlag.Type.METHOD, method.getAccess()); flags = AccessFlag.sort(AccessFlag.Type.METHOD, flags); if (!flags.isEmpty()) { sb.append(AccessFlag.toString(flags)).append(' '); } } /** * Appends the following pattern to the builder: *
	 * ReturnType
	 * 
* * @param sb * Builder to add to. */ protected void buildDeclarationReturnType(@Nonnull StringBuilder sb) { Type methodType = Type.getMethodType(method.getDescriptor()); String returnTypeName = format.filterEscape(methodType.getReturnType().getClassName()); if (returnTypeName.contains(".")) returnTypeName = returnTypeName.substring(returnTypeName.lastIndexOf(".") + 1); sb.append(returnTypeName).append(' '); } /** * Appends the following pattern to the builder: *
	 * methodName
	 * 
* * @param sb * Builder to add to. */ protected void buildDeclarationName(@Nonnull StringBuilder sb) { sb.append(format.filter(method.getName())); } /** * Appends the following pattern to the builder: *
	 * (Type argName, Type argName)
	 * 
* * @param sb * Builder to add to. */ protected void buildDeclarationArgs(@Nonnull StringBuilder sb) { sb.append('('); boolean isVarargs = AccessFlag.isVarargs(method.getAccess()); int varIndex = AccessFlag.isStatic(method.getAccess()) ? 0 : 1; Type methodType = Type.getMethodType(method.getDescriptor()); Type[] argTypes = methodType.getArgumentTypes(); for (int param = 0; param < argTypes.length; param++) { // Get arg type text Type argType = argTypes[param]; String argTypeName = format.filterEscape(argType.getClassName()); if (argTypeName.contains(".")) argTypeName = argTypeName.substring(argTypeName.lastIndexOf(".") + 1); boolean isLast = param == argTypes.length - 1; if (isVarargs && isLast && argType.getSort() == Type.ARRAY) { argTypeName = StringUtil.replaceLast(argTypeName, "[]", "..."); } // Get arg name String name = "p" + varIndex; LocalVariable variable = method.getLocalVariable(varIndex); if (variable != null) { name = format.filter(variable.getName()); } // Append to arg list sb.append(argTypeName).append(' ').append(name); if (!isLast) { sb.append(", "); } // Increment for next var varIndex += argType.getSize(); } sb.append(')'); } /** * Appends the following pattern to the builder: *
	 * throws Item1, Item2, ...
	 * 
* * @param sb * Builder to add to. */ protected void buildDeclarationThrows(@Nonnull StringBuilder sb) { List thrownTypes = method.getThrownTypes(); if (thrownTypes.isEmpty()) return; String shortNames = thrownTypes.stream() .map(t -> format.filterEscape(StringUtil.shortenPath(t))) .collect(Collectors.joining(", ")); sb.append(" throws ").append(shortNames); } /** * Appends the abstract method body to the printer. * * @param sb * Builder to add to. */ protected void appendAbstractBody(@Nonnull StringBuilder sb) { sb.append(';'); } /** * Appends the method body to the printer. * * @param sb * Builder to add to. */ protected void appendBody(@Nonnull StringBuilder sb) { Textifier textifier = new Textifier(); ClassVisitor printVisitor = new ClassVisitor(RecafConstants.getAsmVersion()) { @Override public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { return new TraceMethodVisitor(textifier); } }; classInfo.getClassReader().accept(new MemberFilteringVisitor(printVisitor, method), 0); sb.append(" {\n"); if (!textifier.getText().isEmpty()) { // Pipe ASM's text line model to an output. ByteArrayOutputStream baos = new ByteArrayOutputStream(); PrintWriter writer = new PrintWriter(baos); textifier.print(writer); writer.close(); // Cleanup the output text. String asmDump = baos.toString(StandardCharsets.UTF_8); // Indent it just a bit with our printer and append to the string builder. Printer codePrinter = new Printer(); codePrinter.setIndent(" "); codePrinter.appendMultiLine(asmDump); sb.append(" /*\n"); sb.append(codePrinter); sb.append(" */\n"); } sb.append(" throw new RuntimeException(\"Stub method\");\n"); sb.append("}\n"); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/fallback/print/PrintUtils.java ================================================ package software.coley.recaf.services.decompile.fallback.print; import jakarta.annotation.Nonnull; import org.objectweb.asm.Type; import software.coley.recaf.info.annotation.Annotated; import software.coley.recaf.info.annotation.AnnotationElement; import software.coley.recaf.info.annotation.AnnotationEnumReference; import software.coley.recaf.info.annotation.AnnotationInfo; import software.coley.recaf.services.text.TextFormatConfig; import software.coley.recaf.util.EscapeUtil; import software.coley.recaf.util.StringUtil; import software.coley.recaf.util.Types; import java.util.List; import java.util.Map; import java.util.StringJoiner; import java.util.stream.Collectors; /** * Various printing utilities. * * @author Matt Coley */ public class PrintUtils { /** * @param format * Format config. * @param container * Annotation container. Can be a class, field, or method. * * @return String display of the annotations on the given container. Empty string if there are no annotations. */ @Nonnull public static String annotationsToString(@Nonnull TextFormatConfig format, @Nonnull Annotated container) { // Skip if there are no annotations. List annotations = container.getAnnotations(); if (annotations.isEmpty()) return ""; // Print all annotations. StringBuilder sb = new StringBuilder(); for (AnnotationInfo annotation : annotations) sb.append(annotationToString(format, annotation)).append('\n'); sb.setLength(sb.length() - 1); // Cut off ending '\n' return sb.toString(); } /** * @param format * Format config. * @param annotation * Annotation to represent. * * @return String display of the annotation. */ @Nonnull private static String annotationToString(@Nonnull TextFormatConfig format, @Nonnull AnnotationInfo annotation) { String annotationDesc = annotation.getDescriptor(); if (Types.isValidDesc(annotationDesc)) { Map elements = annotation.getElements(); String annotationName = StringUtil.shortenPath(Type.getType(annotationDesc).getInternalName()); StringBuilder sb = new StringBuilder("@"); sb.append(format.filterEscape(annotationName)); if (!elements.isEmpty()) { if (elements.size() == 1 && elements.get("value") != null) { // If we only have 'value' we can ommit the 'k=' portion of the standard 'k=v' AnnotationElement element = elements.values().iterator().next(); sb.append("(").append(elementToString(format, element)).append(")"); } else { // Print all args in k=v format String args = elements.entrySet().stream() .map(e -> e.getKey() + " = " + elementToString(format, e.getValue())) .collect(Collectors.joining(", ")); sb.append("(").append(args).append(")"); } } return sb.toString(); } else { return "// Invalid annotation removed"; } } /** * @param format * Format config. * @param element * Annotation element to represent. * * @return String display of the annotation element. */ @Nonnull public static String elementToString(@Nonnull TextFormatConfig format, @Nonnull AnnotationElement element) { Object value = element.getElementValue(); return elementValueToString(format, value); } /** * @param format * Format config. * @param value * Annotation element value to represent. * * @return String display of the element value. */ @Nonnull private static String elementValueToString(@Nonnull TextFormatConfig format, @Nonnull Object value) { switch (value) { case String str -> { // String value return '"' + EscapeUtil.escapeStandardAndUnicodeWhitespace(str) + '"'; } case Type type -> { // Class value return format.filter(type.getInternalName()) + ".class"; } case AnnotationInfo subAnnotation -> { // Annotation value return annotationToString(format, subAnnotation); } case AnnotationEnumReference enumReference -> { // Enum value String enumType = Type.getType(enumReference.getDescriptor()).getInternalName(); return format.filter(enumType) + '.' + enumReference.getValue(); } case List list -> { // List of values String elements = list.stream() .map(e -> elementValueToString(format, e)) .collect(Collectors.joining(", ")); return "{ " + elements + " }"; } case boolean[] array -> { StringJoiner str = new StringJoiner(", "); for (boolean b : array) str.add(Boolean.toString(b)); return "{ " + str + " }"; } case byte[] array -> { StringJoiner str = new StringJoiner(", "); for (byte b : array) str.add(Byte.toString(b)); return "{ " + str + " }"; } case char[] array -> { StringJoiner str = new StringJoiner(", "); for (char c : array) str.add(Character.toString(c)); return "{ " + str + " }"; } case short[] array -> { StringJoiner str = new StringJoiner(", "); for (short s : array) str.add(Short.toString(s)); return "{ " + str + " }"; } case int[] array -> { StringJoiner str = new StringJoiner(", "); for (int i : array) str.add(Integer.toString(i)); return "{ " + str + " }"; } case float[] array -> { StringJoiner str = new StringJoiner(", "); for (float f : array) str.add(Float.toString(f)); return "{ " + str + " }"; } case double[] array -> { StringJoiner str = new StringJoiner(", "); for (double d : array) str.add(Double.toString(d)); return "{ " + str + " }"; } case long[] array -> { StringJoiner str = new StringJoiner(", "); for (long l : array) str.add(Long.toString(l)); return "{ " + str + " }"; } default -> { // Primitive return value.toString(); } } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/fallback/print/Printer.java ================================================ package software.coley.recaf.services.decompile.fallback.print; import jakarta.annotation.Nonnull; import software.coley.recaf.util.StringUtil; /** * String printing wrapper of {@link StringBuilder}. * Helps with indentation and line-based print calls. * * @author Matt Coley */ public class Printer { private final StringBuilder out = new StringBuilder(); private String indent; /** * @param indent * New indentation prefix. */ public void setIndent(@Nonnull String indent) { this.indent = indent; } /** * Appends a line with a {@link #setIndent(String) configurable indent}. * * @param line * Line to print. */ public void appendLine(@Nonnull String line) { if (indent != null) out.append(indent); out.append(line).append("\n"); } /** * Appends all lines in the multi-line text. * * @param text * Multi-line text to append. * * @see #appendLine(String) */ public void appendMultiLine(@Nonnull String text) { String[] lines = StringUtil.splitNewline(text); for (String line : lines) appendLine(line); } /** * Append blank new line. */ public void newLine() { out.append('\n'); } @Override public String toString() { return out.toString(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/filter/JvmBytecodeFilter.java ================================================ package software.coley.recaf.services.decompile.filter; import jakarta.annotation.Nonnull; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.decompile.JvmDecompiler; import software.coley.recaf.workspace.model.Workspace; import java.util.Collection; /** * Used to allow interception of bytecode passed to {@link JvmDecompiler} instances. * * @author Matt Coley */ public interface JvmBytecodeFilter { /** * @param workspace * The workspace the class is from. * @param initialClassInfo * Initial information about the class, before any filtering (by other filters) has been applied. * Contains a reference to the original bytecode. * @param bytecode * Input JVM class bytecode. May already be modified from the original bytecode by another filter. * * @return Output JVM class bytecode. */ @Nonnull byte[] filter(@Nonnull Workspace workspace, @Nonnull JvmClassInfo initialClassInfo, @Nonnull byte[] bytecode); /** * @param workspace * The workspace the class is from. * @param initialClassInfo * Initial information about the class, before any filtering (by other filters) has been applied. * @param bytecodeFilters * Collection of filters to apply to the class. * * @return Filtered class model. */ @Nonnull static JvmClassInfo applyFilters(@Nonnull Workspace workspace, @Nonnull JvmClassInfo initialClassInfo, @Nonnull Collection bytecodeFilters) { JvmClassInfo filteredBytecode; if (bytecodeFilters.isEmpty()) { filteredBytecode = initialClassInfo; } else { boolean dirty = false; byte[] bytecode = initialClassInfo.getBytecode(); for (JvmBytecodeFilter filter : bytecodeFilters) { byte[] filtered = filter.filter(workspace, initialClassInfo, bytecode); if (filtered != bytecode) { bytecode = filtered; dirty = true; } } filteredBytecode = dirty ? initialClassInfo.toJvmClassBuilder().adaptFrom(bytecode).build() : initialClassInfo; } return filteredBytecode; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/filter/OutputTextFilter.java ================================================ package software.coley.recaf.services.decompile.filter; import jakarta.annotation.Nonnull; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.workspace.model.Workspace; /** * Used to allow interception of decompiler output before being returned to users. * * @author Matt Coley */ public interface OutputTextFilter { /** * @param workspace * The workspace the class is from. * @param classInfo * Information about the class the decompiled code models. * @param code * Decompiled code. * * @return Filtered decompiled code. */ @Nonnull String filter(@Nonnull Workspace workspace, @Nonnull ClassInfo classInfo, @Nonnull String code); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/procyon/ProcyonConfig.java ================================================ package software.coley.recaf.services.decompile.procyon; import com.strobel.assembler.metadata.CompilerTarget; import com.strobel.decompiler.DecompilerSettings; import com.strobel.decompiler.languages.BytecodeOutputOptions; import com.strobel.decompiler.languages.Language; import com.strobel.decompiler.languages.Languages; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.observables.ObservableBoolean; import software.coley.observables.ObservableInteger; import software.coley.observables.ObservableObject; import software.coley.recaf.config.BasicConfigValue; import software.coley.recaf.services.decompile.BaseDecompilerConfig; import software.coley.recaf.util.ExcludeFromJacocoGeneratedReport; /** * Config for {@link ProcyonDecompiler} * * @author Matt Coley */ @ApplicationScoped @ExcludeFromJacocoGeneratedReport(justification = "Config POJO") public class ProcyonConfig extends BaseDecompilerConfig { private final ObservableBoolean includeLineNumbersInBytecode = new ObservableBoolean(true); private final ObservableBoolean showSyntheticMembers = new ObservableBoolean(false); private final ObservableBoolean alwaysGenerateExceptionVariableForCatchBlocks = new ObservableBoolean(true); private final ObservableBoolean forceFullyQualifiedReferences = new ObservableBoolean(false); private final ObservableBoolean forceExplicitImports = new ObservableBoolean(true); private final ObservableBoolean forceExplicitTypeArguments = new ObservableBoolean(false); private final ObservableBoolean flattenSwitchBlocks = new ObservableBoolean(false); private final ObservableBoolean excludeNestedTypes = new ObservableBoolean(false); private final ObservableBoolean retainRedundantCasts = new ObservableBoolean(false); private final ObservableBoolean retainPointlessSwitches = new ObservableBoolean(false); private final ObservableBoolean isUnicodeOutputEnabled = new ObservableBoolean(false); private final ObservableBoolean includeErrorDiagnostics = new ObservableBoolean(true); private final ObservableBoolean mergeVariables = new ObservableBoolean(false); private final ObservableBoolean disableForEachTransforms = new ObservableBoolean(false); private final ObservableBoolean showDebugLineNumbers = new ObservableBoolean(false); private final ObservableBoolean simplifyMemberReferences = new ObservableBoolean(false); private final ObservableBoolean arePreviewFeaturesEnabled = new ObservableBoolean(false); private final ObservableInteger textBlockLineMinimum = new ObservableInteger(3); private final ObservableObject forcedCompilerTarget = new ObservableObject<>(null); private final ObservableObject bytecodeOutputOptions = new ObservableObject<>(BytecodeOutputOptions.createDefault()); private final ObservableObject languageTarget = new ObservableObject<>(Languages.java()); @Inject public ProcyonConfig() { super("decompiler-procyon" + CONFIG_SUFFIX); addValue(new BasicConfigValue<>("includeLineNumbersInBytecode", boolean.class, includeLineNumbersInBytecode)); addValue(new BasicConfigValue<>("showSyntheticMembers", boolean.class, showSyntheticMembers)); addValue(new BasicConfigValue<>("alwaysGenerateExceptionVariableForCatchBlocks", boolean.class, alwaysGenerateExceptionVariableForCatchBlocks)); addValue(new BasicConfigValue<>("forceFullyQualifiedReferences", boolean.class, forceFullyQualifiedReferences)); addValue(new BasicConfigValue<>("forceExplicitImports", boolean.class, forceExplicitImports)); addValue(new BasicConfigValue<>("forceExplicitTypeArguments", boolean.class, forceExplicitTypeArguments)); addValue(new BasicConfigValue<>("flattenSwitchBlocks", boolean.class, flattenSwitchBlocks)); addValue(new BasicConfigValue<>("excludeNestedTypes", boolean.class, excludeNestedTypes)); addValue(new BasicConfigValue<>("retainRedundantCasts", boolean.class, retainRedundantCasts)); addValue(new BasicConfigValue<>("retainPointlessSwitches", boolean.class, retainPointlessSwitches)); addValue(new BasicConfigValue<>("isUnicodeOutputEnabled", boolean.class, isUnicodeOutputEnabled)); addValue(new BasicConfigValue<>("includeErrorDiagnostics", boolean.class, includeErrorDiagnostics)); addValue(new BasicConfigValue<>("mergeVariables", boolean.class, mergeVariables)); addValue(new BasicConfigValue<>("disableForEachTransforms", boolean.class, disableForEachTransforms)); addValue(new BasicConfigValue<>("showDebugLineNumbers", boolean.class, showDebugLineNumbers)); addValue(new BasicConfigValue<>("simplifyMemberReferences", boolean.class, simplifyMemberReferences)); addValue(new BasicConfigValue<>("arePreviewFeaturesEnabled", boolean.class, arePreviewFeaturesEnabled)); addValue(new BasicConfigValue<>("textBlockLineMinimum", int.class, textBlockLineMinimum)); addValue(new BasicConfigValue<>("forcedCompilerTarget", CompilerTarget.class, forcedCompilerTarget)); addValue(new BasicConfigValue<>("bytecodeOutputOptions", BytecodeOutputOptions.class, bytecodeOutputOptions)); addValue(new BasicConfigValue<>("languageTarget", Language.class, languageTarget)); registerConfigValuesHashUpdates(); } /** * @return Settings wrapper. */ @Nonnull public DecompilerSettings toSettings() { DecompilerSettings decompilerSettings = new DecompilerSettings(); decompilerSettings.setIncludeLineNumbersInBytecode(includeLineNumbersInBytecode.getValue()); decompilerSettings.setShowSyntheticMembers(showSyntheticMembers.getValue()); decompilerSettings.setAlwaysGenerateExceptionVariableForCatchBlocks(alwaysGenerateExceptionVariableForCatchBlocks.getValue()); decompilerSettings.setForceFullyQualifiedReferences(forceFullyQualifiedReferences.getValue()); decompilerSettings.setForceExplicitImports(forceExplicitImports.getValue()); decompilerSettings.setForceExplicitTypeArguments(forceExplicitTypeArguments.getValue()); decompilerSettings.setFlattenSwitchBlocks(flattenSwitchBlocks.getValue()); decompilerSettings.setExcludeNestedTypes(excludeNestedTypes.getValue()); decompilerSettings.setRetainRedundantCasts(retainRedundantCasts.getValue()); decompilerSettings.setRetainPointlessSwitches(retainPointlessSwitches.getValue()); decompilerSettings.setUnicodeOutputEnabled(isUnicodeOutputEnabled.getValue()); decompilerSettings.setIncludeErrorDiagnostics(includeErrorDiagnostics.getValue()); decompilerSettings.setMergeVariables(mergeVariables.getValue()); decompilerSettings.setDisableForEachTransforms(disableForEachTransforms.getValue()); decompilerSettings.setShowDebugLineNumbers(showDebugLineNumbers.getValue()); decompilerSettings.setSimplifyMemberReferences(simplifyMemberReferences.getValue()); decompilerSettings.setPreviewFeaturesEnabled(arePreviewFeaturesEnabled.getValue()); decompilerSettings.setTextBlockLineMinimum(textBlockLineMinimum.getValue()); decompilerSettings.setForcedCompilerTarget(forcedCompilerTarget.getValue()); decompilerSettings.setBytecodeOutputOptions(bytecodeOutputOptions.getValue()); decompilerSettings.setLanguage(languageTarget.getValue()); return decompilerSettings; } @Nonnull public ObservableBoolean getIncludeLineNumbersInBytecode() { return includeLineNumbersInBytecode; } @Nonnull public ObservableBoolean getShowSyntheticMembers() { return showSyntheticMembers; } @Nonnull public ObservableBoolean getAlwaysGenerateExceptionVariableForCatchBlocks() { return alwaysGenerateExceptionVariableForCatchBlocks; } @Nonnull public ObservableBoolean getForceFullyQualifiedReferences() { return forceFullyQualifiedReferences; } @Nonnull public ObservableBoolean getForceExplicitImports() { return forceExplicitImports; } @Nonnull public ObservableBoolean getForceExplicitTypeArguments() { return forceExplicitTypeArguments; } @Nonnull public ObservableBoolean getFlattenSwitchBlocks() { return flattenSwitchBlocks; } @Nonnull public ObservableBoolean getExcludeNestedTypes() { return excludeNestedTypes; } @Nonnull public ObservableBoolean getRetainRedundantCasts() { return retainRedundantCasts; } @Nonnull public ObservableBoolean getRetainPointlessSwitches() { return retainPointlessSwitches; } @Nonnull public ObservableBoolean getIsUnicodeOutputEnabled() { return isUnicodeOutputEnabled; } @Nonnull public ObservableBoolean getIncludeErrorDiagnostics() { return includeErrorDiagnostics; } @Nonnull public ObservableBoolean getMergeVariables() { return mergeVariables; } @Nonnull public ObservableBoolean getDisableForEachTransforms() { return disableForEachTransforms; } @Nonnull public ObservableBoolean getShowDebugLineNumbers() { return showDebugLineNumbers; } @Nonnull public ObservableBoolean getSimplifyMemberReferences() { return simplifyMemberReferences; } @Nonnull public ObservableBoolean getArePreviewFeaturesEnabled() { return arePreviewFeaturesEnabled; } @Nonnull public ObservableInteger getTextBlockLineMinimum() { return textBlockLineMinimum; } @Nonnull public ObservableObject getForcedCompilerTarget() { return forcedCompilerTarget; } @Nonnull public ObservableObject getBytecodeOutputOptions() { return bytecodeOutputOptions; } @Nonnull public ObservableObject getLanguageTarget() { return languageTarget; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/procyon/ProcyonDecompiler.java ================================================ package software.coley.recaf.services.decompile.procyon; import com.google.gson.Gson; import com.google.gson.TypeAdapter; import com.google.gson.TypeAdapterFactory; import com.google.gson.reflect.TypeToken; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; import com.strobel.Procyon; import com.strobel.assembler.metadata.Buffer; import com.strobel.assembler.metadata.CompositeTypeLoader; import com.strobel.assembler.metadata.ITypeLoader; import com.strobel.assembler.metadata.MetadataSystem; import com.strobel.assembler.metadata.TypeReference; import com.strobel.decompiler.DecompilationOptions; import com.strobel.decompiler.DecompilerSettings; import com.strobel.decompiler.PlainTextOutput; import com.strobel.decompiler.languages.Language; import com.strobel.decompiler.languages.Languages; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.decompile.AbstractJvmDecompiler; import software.coley.recaf.services.decompile.DecompileResult; import software.coley.recaf.services.json.GsonProvider; import software.coley.recaf.workspace.model.Workspace; import java.io.IOException; import java.io.StringWriter; /** * Procyon decompiler implementation. * * @author xDark */ @ApplicationScoped public class ProcyonDecompiler extends AbstractJvmDecompiler { public static final String NAME = "Procyon"; private final ProcyonConfig config; /** * New Procyon decompiler instance. * * @param gsonProvider * Gson provider to register deserialization with. * @param config * Config instance. */ @Inject public ProcyonDecompiler(@Nonnull GsonProvider gsonProvider, @Nonnull ProcyonConfig config) { super(NAME, Procyon.version(), config); this.config = config; // Support for mapping the 'language' model we store in the config. LanguageTypeAdapter adapter = new LanguageTypeAdapter(); gsonProvider.addTypeAdapterFactory(new TypeAdapterFactory() { @Override @SuppressWarnings("unchecked") public TypeAdapter create(Gson gson, TypeToken type) { if (Language.class.isAssignableFrom(type.getRawType())) return (TypeAdapter) adapter; return null; } }); } @Nonnull @Override protected DecompileResult decompileInternal(@Nonnull Workspace workspace, @Nonnull JvmClassInfo classInfo) { String name = classInfo.getName(); byte[] bytecode = classInfo.getBytecode(); ITypeLoader loader = new CompositeTypeLoader( new TargetedTypeLoader(name, bytecode), new WorkspaceTypeLoader(workspace) ); DecompilerSettings settings = config.toSettings(); settings.setTypeLoader(loader); MetadataSystem system = new MetadataSystem(loader); TypeReference ref = system.lookupType(name); DecompilationOptions decompilationOptions = new DecompilationOptions(); decompilationOptions.setSettings(settings); StringWriter writer = new StringWriter(); settings.getLanguage().decompileType(ref.resolve(), new PlainTextOutput(writer), decompilationOptions); String decompile = writer.toString(); int configHash = getConfig().getHash(); if (decompile == null) return new DecompileResult(new IllegalStateException("Missing decompilation output"), configHash); return new DecompileResult(decompile, configHash); } /** * Type loader to load a single class file. * Used as the first loader within a {@link CompositeTypeLoader} such that it overrides any * following type loader that could also procure the same class info. */ private record TargetedTypeLoader(String name, byte[] data) implements ITypeLoader { @Override public boolean tryLoadType(String internalName, Buffer buffer) { if (internalName.equals(name)) { byte[] data = this.data; buffer.position(0); buffer.putByteArray(data, 0, data.length); buffer.position(0); return true; } return false; } } /** * Adapter to read/write {@link Language} values from/to {@link ProcyonConfig}. */ private static class LanguageTypeAdapter extends TypeAdapter { @Override public void write(JsonWriter writer, Language language) throws IOException { writer.value(language.getName()); } @Override public Language read(JsonReader reader) throws IOException { if (reader.hasNext()) { String name = reader.nextString(); return Languages.all().stream() .filter(l -> l.getName().equals(name)) .findFirst() .orElse(Languages.java()); } return Languages.java(); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/procyon/WorkspaceTypeLoader.java ================================================ package software.coley.recaf.services.decompile.procyon; import com.strobel.assembler.metadata.Buffer; import com.strobel.assembler.metadata.ITypeLoader; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.workspace.model.Workspace; /** * Type loader that pulls classes from a {@link Workspace}. * * @author xDark */ public final class WorkspaceTypeLoader implements ITypeLoader { private final Workspace workspace; /** * @param workspace * Active workspace. */ public WorkspaceTypeLoader(Workspace workspace) { this.workspace = workspace; } @Override public boolean tryLoadType(String internalName, Buffer buffer) { ClassPathNode node = workspace.findClass(internalName); if (node == null) return false; byte[] data = node.getValue().asJvmClass().getBytecode(); buffer.position(0); buffer.putByteArray(data, 0, data.length); buffer.position(0); return true; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/vineflower/BaseSource.java ================================================ package software.coley.recaf.services.decompile.vineflower; import jakarta.annotation.Nonnull; import org.jetbrains.java.decompiler.main.extern.IContextSource; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.workspace.model.Workspace; import java.io.ByteArrayInputStream; import java.io.InputStream; /** * Base Vineflower class/library source. * * @author therathatter */ public abstract class BaseSource implements IContextSource { protected final JvmClassInfo targetInfo; protected final Workspace workspace; /** * @param workspace * Workspace to pull class files from. * @param targetInfo * Target class to decompile. */ protected BaseSource(@Nonnull Workspace workspace, @Nonnull JvmClassInfo targetInfo) { this.workspace = workspace; this.targetInfo = targetInfo; } @Override public String getName() { return "Recaf"; } @Override public InputStream getInputStream(String resource) { String name = resource.substring(0, resource.length() - IContextSource.CLASS_SUFFIX.length()); if (name.equals(targetInfo.getName())) return new ByteArrayInputStream(targetInfo.getBytecode()); ClassPathNode node = workspace.findClass(name); if (node == null) return null; // VF wants missing data to be null here, not an IOException or empty stream. return new ByteArrayInputStream(node.getValue().asJvmClass().getBytecode()); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/vineflower/ClassSource.java ================================================ package software.coley.recaf.services.decompile.vineflower; import jakarta.annotation.Nonnull; import org.jetbrains.java.decompiler.main.extern.IResultSaver; import software.coley.recaf.info.InnerClassInfo; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.workspace.model.Workspace; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * Single class source for Vineflower. * * @author Matt Coley * @author therathatter */ public class ClassSource extends BaseSource { private final DecompiledOutputSink sink; /** * @param workspace * Workspace to pull class files from. * @param targetInfo * Target class to decompile. */ protected ClassSource(@Nonnull Workspace workspace, @Nonnull JvmClassInfo targetInfo) { super(workspace, targetInfo); sink = new DecompiledOutputSink(targetInfo); } /** * @return Output which holds the decompilation result after the decompilation task completes. */ @Nonnull protected DecompiledOutputSink getSink() { return sink; } @Override public Entries getEntries() { // TODO: Bug in Vineflower makes it so that 'addLibrary' doesn't yield inner info for a class provided with 'addSource' // So for now until this is fixed upstream we will also supply inners here. // This will make Vineflower decompile each inner class separately as well, but its the best fix for now without // too much of a perf hit. List entries = new ArrayList<>(); entries.add(new Entry(targetInfo.getName(), Entry.BASE_VERSION)); for (InnerClassInfo innerClass : targetInfo.getInnerClasses()) { // Only add entry if it exists in the workspace. if (workspace.findClass(innerClass.getInnerClassName()) != null) entries.add(new Entry(innerClass.getName(), Entry.BASE_VERSION)); } return new Entries(entries, Collections.emptyList(), Collections.emptyList()); } @Override public IOutputSink createOutputSink(IResultSaver saver) { return sink; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/vineflower/DecompiledOutputSink.java ================================================ package software.coley.recaf.services.decompile.vineflower; import jakarta.annotation.Nonnull; import org.jetbrains.java.decompiler.main.extern.IContextSource; import software.coley.recaf.info.JvmClassInfo; import java.io.IOException; /** * Output sink for Vineflower decompiler. * * @author therathatter */ public class DecompiledOutputSink implements IContextSource.IOutputSink { protected final JvmClassInfo target; protected final ThreadLocal out = new ThreadLocal<>(); /** * @param target * Target class to get output of. */ protected DecompiledOutputSink(@Nonnull JvmClassInfo target) { this.target = target; } /** * @return Local wrapper of decompilation output. */ @Nonnull protected ThreadLocal getDecompiledOutput() { return out; } @Override public void begin() { // no-op } @Override public void acceptClass(String qualifiedName, String fileName, String content, int[] mapping) { if (target.getName().equals(qualifiedName)) out.set(content); } @Override public void acceptDirectory(String directory) { // no-op } @Override public void acceptOther(String path) { // no-op } @Override public void close() throws IOException { // no-op } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/vineflower/DummyResultSaver.java ================================================ package software.coley.recaf.services.decompile.vineflower; import org.jetbrains.java.decompiler.main.extern.IResultSaver; import software.coley.recaf.util.ExcludeFromJacocoGeneratedReport; import java.util.jar.Manifest; /** * Dummy result saver to prevent Vineflower from trying to touch disk. * * @author therathatter */ @ExcludeFromJacocoGeneratedReport(justification = "We don't use VF file IO, everything stays in memory") public class DummyResultSaver implements IResultSaver { @Override public void saveFolder(String s) { // no-op } @Override public void copyFile(String s, String s1, String s2) { // no-op } @Override public void saveClassFile(String s, String s1, String s2, String s3, int[] ints) { // no-op } @Override public void createArchive(String s, String s1, Manifest manifest) { // no-op } @Override public void saveDirEntry(String s, String s1, String s2) { // no-op } @Override public void copyEntry(String s, String s1, String s2, String s3) { // no-op } @Override public void saveClassEntry(String s, String s1, String s2, String s3, String s4) { // no-op } @Override public void closeArchive(String s, String s1) { // no-op } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/vineflower/LibrarySource.java ================================================ package software.coley.recaf.services.decompile.vineflower; import jakarta.annotation.Nonnull; import org.jetbrains.java.decompiler.main.extern.IContextSource; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.workspace.model.Workspace; import java.util.Collections; import java.util.List; /** * Full library source for Vineflower. * * @author Matt Coley * @author therathatter */ public class LibrarySource extends BaseSource { private final List entries; /** * @param entries * List of context entries in the given workspace. * @param workspace * Workspace to pull class files from. * @param targetInfo * Target class to decompile. */ protected LibrarySource(@Nonnull List entries, @Nonnull Workspace workspace, @Nonnull JvmClassInfo targetInfo) { super(workspace, targetInfo); this.entries = entries; } @Override public Entries getEntries() { return new Entries(entries, Collections.emptyList(), Collections.emptyList()); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/vineflower/VineflowerConfig.java ================================================ package software.coley.recaf.services.decompile.vineflower; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.jetbrains.java.decompiler.main.Fernflower; import org.jetbrains.java.decompiler.main.extern.IFernflowerPreferences; import org.slf4j.event.Level; import software.coley.observables.ObservableBoolean; import software.coley.observables.ObservableObject; import software.coley.recaf.config.BasicConfigValue; import software.coley.recaf.services.decompile.BaseDecompilerConfig; import software.coley.recaf.util.ExcludeFromJacocoGeneratedReport; import java.lang.reflect.Field; import java.util.HashMap; import java.util.Map; /** * Config for {@link VineflowerDecompiler} * * @author therathatter * @see IFernflowerPreferences Source of value definitions. */ @ApplicationScoped @SuppressWarnings("all") // ignore unused refs / typos @ExcludeFromJacocoGeneratedReport(justification = "Config POJO") public class VineflowerConfig extends BaseDecompilerConfig { private final ObservableObject loggingLevel = new ObservableObject<>(Level.WARN); private final ObservableBoolean removeBridge = new ObservableBoolean(true); private final ObservableBoolean removeSynthetic = new ObservableBoolean(true); private final ObservableBoolean decompileInner = new ObservableBoolean(true); private final ObservableBoolean decompileClass_1_4 = new ObservableBoolean(true); private final ObservableBoolean decompileAssertions = new ObservableBoolean(true); private final ObservableBoolean hideEmptySuper = new ObservableBoolean(true); private final ObservableBoolean hideDefaultConstructor = new ObservableBoolean(true); private final ObservableBoolean decompileGenericSignatures = new ObservableBoolean(true); private final ObservableBoolean noExceptionsReturn = new ObservableBoolean(true); private final ObservableBoolean ensureSynchronizedMonitor = new ObservableBoolean(true); private final ObservableBoolean decompileEnum = new ObservableBoolean(true); private final ObservableBoolean removeGetClassNew = new ObservableBoolean(true); private final ObservableBoolean literalsAsIs = new ObservableBoolean(false); private final ObservableBoolean booleanTrueOne = new ObservableBoolean(true); private final ObservableBoolean asciiStringCharacters = new ObservableBoolean(false); private final ObservableBoolean syntheticNotSet = new ObservableBoolean(false); private final ObservableBoolean undefinedParamTypeObject = new ObservableBoolean(true); private final ObservableBoolean useDebugVarNames = new ObservableBoolean(true); private final ObservableBoolean useMethodParameters = new ObservableBoolean(true); private final ObservableBoolean removeEmptyRanges = new ObservableBoolean(true); private final ObservableBoolean finallyDeinline = new ObservableBoolean(true); private final ObservableBoolean ideaNotNullAnnotation = new ObservableBoolean(true); private final ObservableBoolean lambdaToAnonymousClass = new ObservableBoolean(false); private final ObservableBoolean bytecodeSourceMapping = new ObservableBoolean(false); private final ObservableBoolean dumpCodeLines = new ObservableBoolean(false); private final ObservableBoolean ignoreInvalidBytecode = new ObservableBoolean(false); private final ObservableBoolean verifyAnonymousClasses = new ObservableBoolean(false); private final ObservableBoolean ternaryConstantSimplification = new ObservableBoolean(false); private final ObservableBoolean overrideAnnotation = new ObservableBoolean(true); private final ObservableBoolean patternMatching = new ObservableBoolean(true); private final ObservableBoolean tryLoopFix = new ObservableBoolean(true); private final ObservableBoolean ternaryConditions = new ObservableBoolean(true); private final ObservableBoolean switchExpressions = new ObservableBoolean(true); private final ObservableBoolean showHiddenStatements = new ObservableBoolean(false); private final ObservableBoolean simplifyStackSecondPass = new ObservableBoolean(true); private final ObservableBoolean verifyVariableMerges = new ObservableBoolean(false); private final ObservableBoolean decompilePreview = new ObservableBoolean(true); private final ObservableBoolean explicitGenericArguments = new ObservableBoolean(false); private final ObservableBoolean inlineSimpleLambdas = new ObservableBoolean(true); private final ObservableBoolean useJadVarNaming = new ObservableBoolean(false); private final ObservableBoolean useJadParameterNaming = new ObservableBoolean(false); private final ObservableBoolean skipExtraFiles = new ObservableBoolean(false); private final ObservableBoolean warnInconsistentInnerClasses = new ObservableBoolean(true); private final ObservableBoolean dumpBytecodeOnError = new ObservableBoolean(true); private final ObservableBoolean dumpExceptionOnError = new ObservableBoolean(true); private final ObservableBoolean decompilerComments = new ObservableBoolean(false); private final ObservableBoolean sourceFileComments = new ObservableBoolean(false); private final ObservableBoolean decompileComplexCondys = new ObservableBoolean(false); private final ObservableBoolean forceJsrInline = new ObservableBoolean(false); public static void main(String[] args) { for (Field field : IFernflowerPreferences.class.getDeclaredFields()) { try { IFernflowerPreferences.Name name = field.getDeclaredAnnotation(IFernflowerPreferences.Name.class); String key = (String) field.get(null); System.out.println("service.decompile.impl.decompiler-vineflower-config." + key + "=" + name.value()); } catch (Throwable t) {} } } @Inject public VineflowerConfig() { super("decompiler-vineflower" + CONFIG_SUFFIX); addValue(new BasicConfigValue<>("logging-level", Level.class, loggingLevel)); addValue(new BasicConfigValue<>("remove-bridge", boolean.class, removeBridge)); addValue(new BasicConfigValue<>("remove-synthetic", boolean.class, removeSynthetic)); addValue(new BasicConfigValue<>("decompile-inner", boolean.class, decompileInner)); addValue(new BasicConfigValue<>("decompile-java4", boolean.class, decompileClass_1_4)); addValue(new BasicConfigValue<>("decompile-assert", boolean.class, decompileAssertions)); addValue(new BasicConfigValue<>("hide-empty-super", boolean.class, hideEmptySuper)); addValue(new BasicConfigValue<>("hide-default-constructor", boolean.class, hideDefaultConstructor)); addValue(new BasicConfigValue<>("decompile-generics", boolean.class, decompileGenericSignatures)); addValue(new BasicConfigValue<>("incorporate-returns", boolean.class, noExceptionsReturn)); addValue(new BasicConfigValue<>("ensure-synchronized-monitors", boolean.class, ensureSynchronizedMonitor)); addValue(new BasicConfigValue<>("decompile-enums", boolean.class, decompileEnum)); addValue(new BasicConfigValue<>("decompile-preview", boolean.class, decompilePreview)); addValue(new BasicConfigValue<>("remove-getclass", boolean.class, removeGetClassNew)); addValue(new BasicConfigValue<>("keep-literals", boolean.class, literalsAsIs)); addValue(new BasicConfigValue<>("boolean-as-int", boolean.class, booleanTrueOne)); addValue(new BasicConfigValue<>("ascii-strings", boolean.class, asciiStringCharacters)); addValue(new BasicConfigValue<>("synthetic-not-set", boolean.class, syntheticNotSet)); addValue(new BasicConfigValue<>("undefined-as-object", boolean.class, undefinedParamTypeObject)); addValue(new BasicConfigValue<>("use-lvt-names", boolean.class, useDebugVarNames)); addValue(new BasicConfigValue<>("use-method-parameters", boolean.class, useMethodParameters)); addValue(new BasicConfigValue<>("remove-empty-try-catch", boolean.class, removeEmptyRanges)); addValue(new BasicConfigValue<>("decompile-finally", boolean.class, finallyDeinline)); addValue(new BasicConfigValue<>("lambda-to-anonymous-class", boolean.class, lambdaToAnonymousClass)); addValue(new BasicConfigValue<>("bytecode-source-mapping", boolean.class, bytecodeSourceMapping)); addValue(new BasicConfigValue<>("__dump_original_lines__", boolean.class, dumpCodeLines)); addValue(new BasicConfigValue<>("ignore-invalid-bytecode", boolean.class, ignoreInvalidBytecode)); addValue(new BasicConfigValue<>("verify-anonymous-classes", boolean.class, verifyAnonymousClasses)); addValue(new BasicConfigValue<>("ternary-constant-simplification", boolean.class, ternaryConstantSimplification)); addValue(new BasicConfigValue<>("pattern-matching", boolean.class, patternMatching)); addValue(new BasicConfigValue<>("try-loop-fix", boolean.class, tryLoopFix)); addValue(new BasicConfigValue<>("ternary-in-if", boolean.class, ternaryConditions)); addValue(new BasicConfigValue<>("decompile-switch-expressions", boolean.class, switchExpressions)); addValue(new BasicConfigValue<>("show-hidden-statements", boolean.class, showHiddenStatements)); addValue(new BasicConfigValue<>("override-annotation", boolean.class, overrideAnnotation)); addValue(new BasicConfigValue<>("simplify-stack", boolean.class, simplifyStackSecondPass)); addValue(new BasicConfigValue<>("verify-merges", boolean.class, verifyVariableMerges)); addValue(new BasicConfigValue<>("explicit-generics", boolean.class, explicitGenericArguments)); addValue(new BasicConfigValue<>("inline-simple-lambdas", boolean.class, inlineSimpleLambdas)); addValue(new BasicConfigValue<>("skip-extra-files", boolean.class, skipExtraFiles)); addValue(new BasicConfigValue<>("warn-inconsistent-inner-attributes", boolean.class, warnInconsistentInnerClasses)); addValue(new BasicConfigValue<>("dump-bytecode-on-error", boolean.class, dumpBytecodeOnError)); addValue(new BasicConfigValue<>("dump-exception-on-error", boolean.class, dumpExceptionOnError)); addValue(new BasicConfigValue<>("decompiler-comments", boolean.class, decompilerComments)); addValue(new BasicConfigValue<>("sourcefile-comments", boolean.class, sourceFileComments)); addValue(new BasicConfigValue<>("decompile-complex-constant-dynamic", boolean.class, decompileComplexCondys)); addValue(new BasicConfigValue<>("force-jsr-inline", boolean.class, forceJsrInline)); registerConfigValuesHashUpdates(); } /** * @return Map of values to pass to the {@link Fernflower} instance. */ @Nonnull protected Map getFernflowerProperties() { Map properties = new HashMap<>(IFernflowerPreferences.DEFAULTS); getValues().forEach((key, value) -> { if (value.getValue() instanceof Boolean bool) properties.put(key, bool ? "1" : "0"); }); // We NEVER want kotlin output. It will break our AST parser. properties.put("kt-enable", "0"); return properties; } /** * @return Level to use for {@link VineflowerLogger}. */ @Nonnull public ObservableObject getLoggingLevel() { return loggingLevel; } @Nonnull public ObservableBoolean getRemoveBridge() { return removeBridge; } @Nonnull public ObservableBoolean getRemoveSynthetic() { return removeSynthetic; } @Nonnull public ObservableBoolean getDecompileInner() { return decompileInner; } @Nonnull public ObservableBoolean getDecompileClass_1_4() { return decompileClass_1_4; } @Nonnull public ObservableBoolean getDecompileAssertions() { return decompileAssertions; } @Nonnull public ObservableBoolean getHideEmptySuper() { return hideEmptySuper; } @Nonnull public ObservableBoolean getHideDefaultConstructor() { return hideDefaultConstructor; } @Nonnull public ObservableBoolean getDecompileGenericSignatures() { return decompileGenericSignatures; } @Nonnull public ObservableBoolean getNoExceptionsReturn() { return noExceptionsReturn; } @Nonnull public ObservableBoolean getEnsureSynchronizedMonitor() { return ensureSynchronizedMonitor; } @Nonnull public ObservableBoolean getDecompileEnum() { return decompileEnum; } @Nonnull public ObservableBoolean getRemoveGetClassNew() { return removeGetClassNew; } @Nonnull public ObservableBoolean getLiteralsAsIs() { return literalsAsIs; } @Nonnull public ObservableBoolean getBooleanTrueOne() { return booleanTrueOne; } @Nonnull public ObservableBoolean getAsciiStringCharacters() { return asciiStringCharacters; } @Nonnull public ObservableBoolean getSyntheticNotSet() { return syntheticNotSet; } @Nonnull public ObservableBoolean getUndefinedParamTypeObject() { return undefinedParamTypeObject; } @Nonnull public ObservableBoolean getUseDebugVarNames() { return useDebugVarNames; } @Nonnull public ObservableBoolean getUseMethodParameters() { return useMethodParameters; } @Nonnull public ObservableBoolean getRemoveEmptyRanges() { return removeEmptyRanges; } @Nonnull public ObservableBoolean getFinallyDeinline() { return finallyDeinline; } @Nonnull public ObservableBoolean getIdeaNotNullAnnotation() { return ideaNotNullAnnotation; } @Nonnull public ObservableBoolean getLambdaToAnonymousClass() { return lambdaToAnonymousClass; } @Nonnull public ObservableBoolean getBytecodeSourceMapping() { return bytecodeSourceMapping; } @Nonnull public ObservableBoolean getDumpCodeLines() { return dumpCodeLines; } @Nonnull public ObservableBoolean getIgnoreInvalidBytecode() { return ignoreInvalidBytecode; } @Nonnull public ObservableBoolean getVerifyAnonymousClasses() { return verifyAnonymousClasses; } @Nonnull public ObservableBoolean getTernaryConstantSimplification() { return ternaryConstantSimplification; } @Nonnull public ObservableBoolean getOverrideAnnotation() { return overrideAnnotation; } @Nonnull public ObservableBoolean getPatternMatching() { return patternMatching; } @Nonnull public ObservableBoolean getTryLoopFix() { return tryLoopFix; } @Nonnull public ObservableBoolean getTernaryConditions() { return ternaryConditions; } @Nonnull public ObservableBoolean getSwitchExpressions() { return switchExpressions; } @Nonnull public ObservableBoolean getShowHiddenStatements() { return showHiddenStatements; } @Nonnull public ObservableBoolean getSimplifyStackSecondPass() { return simplifyStackSecondPass; } @Nonnull public ObservableBoolean getVerifyVariableMerges() { return verifyVariableMerges; } @Nonnull public ObservableBoolean getDecompilePreview() { return decompilePreview; } @Nonnull public ObservableBoolean getExplicitGenericArguments() { return explicitGenericArguments; } @Nonnull public ObservableBoolean getInlineSimpleLambdas() { return inlineSimpleLambdas; } @Nonnull public ObservableBoolean getUseJadVarNaming() { return useJadVarNaming; } @Nonnull public ObservableBoolean getUseJadParameterNaming() { return useJadParameterNaming; } @Nonnull public ObservableBoolean getSkipExtraFiles() { return skipExtraFiles; } @Nonnull public ObservableBoolean getWarnInconsistentInnerClasses() { return warnInconsistentInnerClasses; } @Nonnull public ObservableBoolean getDumpBytecodeOnError() { return dumpBytecodeOnError; } @Nonnull public ObservableBoolean getDumpExceptionOnError() { return dumpExceptionOnError; } @Nonnull public ObservableBoolean getDecompilerComments() { return decompilerComments; } @Nonnull public ObservableBoolean getSourceFileComments() { return sourceFileComments; } @Nonnull public ObservableBoolean getDecompileComplexCondys() { return decompileComplexCondys; } @Nonnull public ObservableBoolean getForceJsrInline() { return forceJsrInline; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/vineflower/VineflowerDecompiler.java ================================================ package software.coley.recaf.services.decompile.vineflower; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.jetbrains.java.decompiler.main.Fernflower; import org.jetbrains.java.decompiler.main.extern.IFernflowerLogger; import org.jetbrains.java.decompiler.main.extern.IResultSaver; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.decompile.AbstractJvmDecompiler; import software.coley.recaf.services.decompile.DecompileResult; import software.coley.recaf.workspace.model.Workspace; /** * Vineflower decompiler implementation. * * @author therathatter */ @ApplicationScoped public class VineflowerDecompiler extends AbstractJvmDecompiler { public static final String NAME = "Vineflower"; private final VineflowerConfig config; private final IFernflowerLogger logger; private final IResultSaver dummySaver = new DummyResultSaver(); private final WorkspaceEntriesCache workspaceEntriesCache = new WorkspaceEntriesCache(); /** * New Vineflower decompiler instance. * * @param config * Decompiler configuration. */ @Inject public VineflowerDecompiler(@Nonnull VineflowerConfig config) { // Change this version to be dynamic when / if the Vineflower authors make a function that returns the version... super(NAME, "1.11.2", config); this.config = config; logger = new VineflowerLogger(config); } @Nonnull @Override public DecompileResult decompileInternal(@Nonnull Workspace workspace, @Nonnull JvmClassInfo info) { Fernflower fernflower = new Fernflower(dummySaver, config.getFernflowerProperties(), logger); try { ClassSource source = new ClassSource(workspace, info); fernflower.addSource(source); fernflower.addLibrary(new LibrarySource(workspaceEntriesCache.getCachedEntries(workspace), workspace, info)); fernflower.decompileContext(); String decompiled = source.getSink().getDecompiledOutput().get(); if (decompiled == null || decompiled.isEmpty()) return new DecompileResult(new IllegalStateException("Missing decompilation output"), config.getHash()); return new DecompileResult(decompiled, config.getHash()); } catch (Exception e) { return new DecompileResult(e, config.getHash()); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/vineflower/VineflowerLogger.java ================================================ package software.coley.recaf.services.decompile.vineflower; import jakarta.annotation.Nonnull; import org.jetbrains.java.decompiler.main.extern.IFernflowerLogger; import org.slf4j.Logger; import org.slf4j.event.Level; import software.coley.observables.ObservableObject; import software.coley.recaf.analytics.logging.Logging; /** * Logger for Vineflower * * @author therathatter */ public class VineflowerLogger extends IFernflowerLogger { private static final Logger logger = Logging.get(VineflowerLogger.class); private static final String VF_PREFIX = "VF: "; private final ObservableObject level; public VineflowerLogger(@Nonnull VineflowerConfig config) { this.level = config.getLoggingLevel(); } @Override public void writeMessage(String message, Severity severity) { switch (severity) { case TRACE -> { if (level.getValue().compareTo(Level.TRACE) >= 0) logger.trace(VF_PREFIX + message); } case INFO -> { if (level.getValue().compareTo(Level.INFO) >= 0) logger.info(VF_PREFIX + message); } case WARN -> { if (level.getValue().compareTo(Level.WARN) >= 0) logger.warn(VF_PREFIX + message); } case ERROR -> logger.error(VF_PREFIX + message); } } @Override public void writeMessage(String message, Severity severity, Throwable throwable) { logger.error(VF_PREFIX + message, throwable); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/decompile/vineflower/WorkspaceEntriesCache.java ================================================ package software.coley.recaf.services.decompile.vineflower; import jakarta.annotation.Nonnull; import org.jetbrains.java.decompiler.main.extern.IContextSource; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.List; import java.util.stream.Collectors; /** * Caches the list of {@link IContextSource.Entry} items in a workspace. * * @author Matt Coley * @see LibrarySource */ public class WorkspaceEntriesCache { private List cache; private int lastWorkspaceHash; /** * @param workspace * Workspace to get cached entries of. * * @return List of all distinctly named entries for classes in the workspace. */ @Nonnull public synchronized List getCachedEntries(@Nonnull Workspace workspace) { List local = cache; int workspaceHash = workspace.hashCode(); if (local == null || workspaceHash != lastWorkspaceHash) { local = workspace.getAllResources(false).stream() .flatMap(WorkspaceResource::jvmAllClassBundleStreamRecursive) .flatMap(c -> c.keySet().stream()) .distinct() .map(className -> new IContextSource.Entry(className, IContextSource.Entry.BASE_VERSION)) .collect(Collectors.toList()); cache = local; lastWorkspaceHash = workspaceHash; } return local; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/CallResultInliningTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import it.unimi.dsi.fastutil.objects.Object2BooleanArrayMap; import it.unimi.dsi.fastutil.objects.Object2BooleanMap; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import jakarta.inject.Inject; import org.objectweb.asm.Opcodes; import org.objectweb.asm.Type; import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.InsnList; import org.objectweb.asm.tree.InsnNode; import org.objectweb.asm.tree.MethodInsnNode; import org.objectweb.asm.tree.MethodNode; import org.objectweb.asm.tree.analysis.Frame; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.services.inheritance.InheritanceGraphService; import software.coley.recaf.services.transform.ClassTransformer; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.util.analysis.eval.EvaluationResult; import software.coley.recaf.util.analysis.eval.EvaluationYieldResult; import software.coley.recaf.util.analysis.eval.FieldCacheManager; import software.coley.recaf.util.analysis.eval.Evaluator; import software.coley.recaf.util.analysis.value.DoubleValue; import software.coley.recaf.util.analysis.value.LongValue; import software.coley.recaf.util.analysis.value.ReValue; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Set; /** * A transformer that inlines method calls that can be fully evaluated. * * @author Matt Coley */ @Dependent public class CallResultInliningTransformer implements JvmClassTransformer { private final static int MAX_STEPS = 20_000; // TODO: Make configurable private final InheritanceGraphService graphService; private final Object2BooleanMap canBeEvaluatedMap = new Object2BooleanArrayMap<>(); private final FieldCacheManager fieldCacheManager = new FieldCacheManager(); private InheritanceGraph inheritanceGraph; private Evaluator evaluator; @Inject public CallResultInliningTransformer(@Nonnull InheritanceGraphService graphService) { this.graphService = graphService; } @Override public void setup(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace) { inheritanceGraph = graphService.getOrCreateInheritanceGraph(workspace); evaluator = new Evaluator(workspace, context.newInterpreter(inheritanceGraph), new FieldCacheManager(), MAX_STEPS, false); } @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { boolean dirty = false; String className = initialClassState.getName(); ClassNode node = context.getNode(bundle, initialClassState); for (MethodNode method : node.methods) { // Skip if abstract. InsnList instructions = method.instructions; if (instructions == null) continue; Frame[] frames = context.analyze(inheritanceGraph, node, method); for (int i = instructions.size() - 1; i >= 0; i--) { AbstractInsnNode insn = instructions.get(i); if (insn.getOpcode() == Opcodes.INVOKESTATIC && insn instanceof MethodInsnNode min) { Frame frame = frames[i]; if (frame == null) continue; // Collect arguments. Type methodType = Type.getMethodType(min.desc); List arguments = new ArrayList<>(methodType.getArgumentCount()); for (int j = 0; j < methodType.getArgumentCount(); j++) arguments.addFirst(frame.getStack(frame.getStackSize() - 1 - j)); // All arguments must have known values. if (arguments.stream().anyMatch(v -> !v.hasKnownValue())) continue; // Target method must be able to be evaluated. if (!canEvaluate(min)) continue; // Reset instance support before each evaluation to prevent state pollution. fieldCacheManager.reset(); // Attempt evaluation. If it yields a value, replace the call with the result. EvaluationResult result = evaluator.evaluate(min.owner, min.name, min.desc, null, arguments); if (result instanceof EvaluationYieldResult(ReValue retVal)) { AbstractInsnNode replacement = OpaqueConstantFoldingTransformer.toInsn(retVal); if (replacement != null) { for (int arg = arguments.size() - 1; arg >= 0; arg--) { ReValue argValue = arguments.get(arg); if (argValue instanceof LongValue || argValue instanceof DoubleValue) instructions.insertBefore(min, new InsnNode(Opcodes.POP2)); else instructions.insertBefore(min, new InsnNode(Opcodes.POP)); } instructions.set(min, replacement); dirty = true; } } } } } if (dirty) context.setNode(bundle, initialClassState, node); } @Nonnull @Override public Set> recommendedSuccessors() { // This transformer results in the creation of a lot of POP/POP2 instructions. // The stack-operation folding transformer can clean up afterward. return Collections.singleton(OpaqueConstantFoldingTransformer.class); } @Nonnull @Override public String name() { return "Call result inlining"; } private boolean canEvaluate(@Nonnull MethodInsnNode min) { String key = min.owner + "." + min.name + min.desc; synchronized (canBeEvaluatedMap) { return canBeEvaluatedMap.computeIfAbsent(key, k -> evaluator.canEvaluate(min.owner, min.name, min.desc)); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/CycleClassRemovingTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import jakarta.inject.Inject; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.services.inheritance.InheritanceGraphService; import software.coley.recaf.services.inheritance.InheritanceVertex; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.services.workspace.WorkspaceManager; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; /** * A transformer that removes any class with cyclic inheritance. * * @author Matt Coley */ @Dependent public class CycleClassRemovingTransformer implements JvmClassTransformer { private final InheritanceGraphService graphService; private final WorkspaceManager workspaceManager; private InheritanceGraph inheritanceGraph; @Inject public CycleClassRemovingTransformer(@Nonnull WorkspaceManager workspaceManager, @Nonnull InheritanceGraphService graphService) { this.workspaceManager = workspaceManager; this.graphService = graphService; } @Override public void setup(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace) { inheritanceGraph = graphService.getOrCreateInheritanceGraph(workspace); } @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { InheritanceVertex vertex = inheritanceGraph.getVertex(initialClassState.getName()); if (vertex != null && vertex.isLoop()) context.markClassForRemoval(initialClassState); } @Nonnull @Override public String name() { return "Cycle class removal"; } @Override public boolean pruneAfterNoWork() { // Other transformers should not introduce cyclic classes, // so once the work is done there is no need to re-process classes on following passes. return true; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/DeadCodeRemovingTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.InsnList; import org.objectweb.asm.tree.JumpInsnNode; import org.objectweb.asm.tree.LabelNode; import org.objectweb.asm.tree.LocalVariableNode; import org.objectweb.asm.tree.LookupSwitchInsnNode; import org.objectweb.asm.tree.MethodNode; import org.objectweb.asm.tree.TableSwitchInsnNode; import org.objectweb.asm.tree.TryCatchBlockNode; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.transform.ClassTransformer; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.util.AsmInsnUtil; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; import java.util.IdentityHashMap; import java.util.List; import java.util.Queue; import java.util.Set; import static org.objectweb.asm.Opcodes.NOP; import static software.coley.recaf.util.AsmInsnUtil.fixMissingVariableLabels; /** * A transformer that removes dead code. * * @author Matt Coley */ @Dependent public class DeadCodeRemovingTransformer implements JvmClassTransformer { @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { boolean dirty = false; String className = initialClassState.getName(); ClassNode node = context.getNode(bundle, initialClassState); for (MethodNode method : node.methods) dirty |= prune(node, method); if (dirty) context.setNode(bundle, initialClassState, node); } public boolean prune(@Nonnull ClassNode node, @Nonnull MethodNode method) throws TransformationException { InsnList instructions = method.instructions; if (instructions == null || instructions.size() == 0) return false; // Collect try blocks and the instructions within the start-end range (exclusive) List tryCatches = new ArrayList<>(method.tryCatchBlocks.size()); for (TryCatchBlockNode block : method.tryCatchBlocks) { List blockInsns = new ArrayList<>(); AbstractInsnNode insn = block.start; LabelNode end = block.end; while (insn != null) { insn = insn.getNext(); if (insn == end || insn == null) break; blockInsns.add(insn); } tryCatches.add(new TryCatch(block, blockInsns)); } boolean dirty = false; try { // Compute which instructions are visited by walking the method's control flow. Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); List flowStarts = new ArrayList<>(); flowStarts.add(instructions.getFirst()); for (TryCatch tryCatch : tryCatches) flowStarts.add(tryCatch.block.handler); visit(visited, flowStarts); // Prune any instructions not visited. int end = instructions.size() - 1; for (int i = end; i >= 0; i--) { AbstractInsnNode insn = instructions.get(i); if (!visited.contains(insn) || insn.getOpcode() == NOP) { // Don't prune the tail label even if it is "dead" because the last method instruction // is terminal like 'return' or 'athrow'. The label will just get added back automatically // and cause the transform process to loop on repeat. if (i == end && insn.getType() == AbstractInsnNode.LABEL) continue; // Keep try-catch labels for now. if (insn.getType() == AbstractInsnNode.LABEL && method.tryCatchBlocks.stream().anyMatch(tryCatch -> { if (insn == tryCatch.start) return true; if (insn == tryCatch.end) return true; return insn == tryCatch.handler; })) continue; // Remove instruction from method. instructions.remove(insn); // Remove from any catch block's visited instructions. for (TryCatch tryCatch : tryCatches) tryCatch.visitedInstructions.remove(insn); // Mark as dirty. dirty = true; } } // If we have removed all the instructions of a try block's start-end range (because they're dead code) // then the try-catch block entry can be removed. for (TryCatch tryCatch : tryCatches) { if (tryCatch.visitedInstructions.isEmpty()) { method.tryCatchBlocks.remove(tryCatch.block); } } // Ensure that after dead code removal (or any other transformers not cleaning up) // that all variables have labels that reside in the method code list. List variables = method.localVariables; if (variables != null && variables.stream().anyMatch(l -> !instructions.contains(l.start) || !instructions.contains(l.end))) { fixMissingVariableLabels(method); dirty = true; } } catch (Throwable t) { throw new TransformationException("Error encountered when removing dead code", t); } return dirty; } private static void visit(@Nonnull Set visited, @Nonnull List startingPoints) { Queue todo = new ArrayDeque<>(startingPoints); while (!todo.isEmpty()) { AbstractInsnNode insn = todo.remove(); while (insn != null && visited.add(insn)) { handleNext: { switch (insn.getType()) { case AbstractInsnNode.INSN -> { if (AsmInsnUtil.isTerminalOrAlwaysTakeFlowControl(insn.getOpcode())) break handleNext; } case AbstractInsnNode.JUMP_INSN -> { JumpInsnNode jin = (JumpInsnNode) insn; todo.add(jin.label); if (AsmInsnUtil.isTerminalOrAlwaysTakeFlowControl(jin.getOpcode())) break handleNext; } case AbstractInsnNode.TABLESWITCH_INSN -> { TableSwitchInsnNode tsin = (TableSwitchInsnNode) insn; todo.add(tsin.dflt); todo.addAll(tsin.labels); break handleNext; } case AbstractInsnNode.LOOKUPSWITCH_INSN -> { LookupSwitchInsnNode lsin = (LookupSwitchInsnNode) insn; todo.add(lsin.dflt); todo.addAll(lsin.labels); break handleNext; } } insn = insn.getNext(); } } } } @Nonnull @Override public Set> recommendedPredecessors() { // Having opaque predicates replaced with direct GOTO or fall-through // will allow this transformer to properly detect dead code. return Collections.singleton(OpaquePredicateFoldingTransformer.class); } @Nonnull @Override public String name() { return "Dead code removal"; } record TryCatch(@Nonnull TryCatchBlockNode block, @Nonnull List visitedInstructions) { boolean hasInsn(@Nonnull AbstractInsnNode insn) { return visitedInstructions.contains(insn); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/DuplicateAnnotationRemovingTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.util.visitors.DuplicateAnnotationRemovingVisitor; import software.coley.recaf.util.visitors.IllegalAnnotationRemovingVisitor; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; /** * A transformer that removes any invalid annotations from classes and any of their declared members. * * @author Matt Coley */ @Dependent public class DuplicateAnnotationRemovingTransformer implements JvmClassTransformer { @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { // Adapt the class bytes by removing any duplicate annotation. ClassReader reader = new ClassReader(context.getBytecode(bundle, initialClassState)); ClassWriter writer = new ClassWriter(reader, 0); DuplicateAnnotationRemovingVisitor remover = new DuplicateAnnotationRemovingVisitor(writer); reader.accept(remover, initialClassState.getClassReaderFlags()); // If the visitor did work, update the class. if (remover.hasDetectedDuplicateAnnotations()) context.setBytecode(bundle, initialClassState, writer.toByteArray()); } @Nonnull @Override public String name() { return "Duplicate annotation removal"; } @Override public boolean pruneAfterNoWork() { // Other transformers should not introduce duplicate annotations, // so once the work is done there is no need to re-process classes on following passes. return true; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/DuplicateCatchMergingTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.InsnList; import org.objectweb.asm.tree.JumpInsnNode; import org.objectweb.asm.tree.LabelNode; import org.objectweb.asm.tree.MethodNode; import org.objectweb.asm.tree.TryCatchBlockNode; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.util.BlwUtil; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.ArrayList; import java.util.Collections; import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; import static software.coley.recaf.util.AsmInsnUtil.*; /** * A transformer that removes duplicate code in try-catch handler blocks. * * @author Matt Coley */ @Dependent public class DuplicateCatchMergingTransformer implements JvmClassTransformer { /** * Allows us to skip blocks that are too simple to bother merging. * For instance: *
    *
  • {@code { throw e; }}
  • *
  • {@code { e.printStacktrace(); }}
  • *
  • {@code { no-op }}
  • *
*/ private static final int MIN_BLOCK_THRESHOLD = 4; @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { boolean isTransformed = false; ClassNode node = context.getNode(bundle, initialClassState); for (MethodNode method : node.methods) { InsnList instructions = method.instructions; if (instructions == null) continue; if (method.tryCatchBlocks == null || method.tryCatchBlocks.size() <= 1) continue; // Build model of catch block contents. Set visitedHandlers = Collections.newSetFromMap(new IdentityHashMap<>()); Map catchBlocks = new IdentityHashMap<>(); for (TryCatchBlockNode tryCatchBlock : method.tryCatchBlocks) { List catchBlockInsns = new ArrayList<>(); AbstractInsnNode insn = tryCatchBlock.handler; // Skip if we've already visited this block. if (!visitedHandlers.add(insn)) continue; // Build block contents. while (insn != null) { // Only include non-metadata/filler instructions. if (insn.getType() != AbstractInsnNode.FRAME && insn.getOpcode() != NOP) catchBlockInsns.add(insn); // Abort when we encounter an exit or control flow. if (isReturn(insn) || isFlowControl(insn)) break; insn = insn.getNext(); } // Skip if the block is too small. if (catchBlockInsns.size() < MIN_BLOCK_THRESHOLD) continue; // Skip if there is control flow pointing to a contained label. if (hasExternalFlowIntoCatchBlock(method, catchBlockInsns)) continue; catchBlocks.put(tryCatchBlock, new CodeBlock(catchBlockInsns)); } // Sort the blocks by their position in the method code, and then redirect the control flow of earlier // catch blocks to the last catch block handler with equivalent code. List blocks = catchBlocks.values().stream() .sorted() .toList(); for (int i = 0; i < blocks.size() - 1; i++) { CodeBlock block = blocks.get(i); for (int j = blocks.size() - 1; j > i; j--) { CodeBlock laterBlock = blocks.get(j); if (block.equals(laterBlock)) { block.pruneContent(instructions); block.redirectTo(instructions, (LabelNode) laterBlock.instructions.getFirst()); isTransformed = true; break; } } } } if (isTransformed) { context.setRecomputeFrames(initialClassState.getName()); context.setNode(bundle, initialClassState, node); } } private static boolean hasExternalFlowIntoCatchBlock(@Nonnull MethodNode method, @Nonnull List block) { // We pass 'includeFirstInsn = false' because no catch block should be used to point to a label // that is in the middle of the block. However, if the handler is the start of the block, that is fine // as that is expected as a potential handler. if (hasHandlerFlowIntoBlock(method, block, false)) return true; // No control flow instruction should point to this block *at all*. // If we observe this to be the case, it would be very hard to manipulate the contents of this block // without breaking the flow of the method. return hasInboundFlowReferences(method, block); } @Nonnull @Override public String name() { return "Duplicate catch merging"; } /** * Container for multiple instructions. Uses the disassembled presentation as a key * because ASM does not implement equals/hashCodes for their instruction models. */ private final static class CodeBlock implements Comparable { private final List instructions; private final String disassembled; private final int index; private CodeBlock(@Nonnull List instructions) { this.instructions = instructions; this.disassembled = instructions.stream() .skip(1) // Skip first label, which will always be unique .map(BlwUtil::toString) .collect(Collectors.joining("\n")); this.index = indexOf(instructions.getFirst()); } /** * Prunes the instructions of this block that are not the initial label. * * @param container * Method instruction container to remove the instructions from. */ public void pruneContent(@Nonnull InsnList container) { while (instructions.size() > 1) { AbstractInsnNode next = instructions.remove(1); container.remove(next); } } /** * Redirects the control flow of this block to the given label. * * @param container * Method instruction container to modify control flow of. * @param target * Target label to jump to. */ public void redirectTo(@Nonnull InsnList container, @Nonnull LabelNode target) { AbstractInsnNode first = instructions.getFirst(); container.insert(first, new JumpInsnNode(GOTO, target)); } /** * @return Starting index in the method code of this block. */ public int getIndex() { return index; } /** * @return Instructions contained by this block. */ @Nonnull public List getInstructions() { return instructions; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof CodeBlock codeBlock)) return false; return disassembled.equals(codeBlock.disassembled); } @Override public int hashCode() { return disassembled.hashCode(); } @Override public String toString() { return disassembled; } @Override public int compareTo(CodeBlock o) { // We already know only one code-block can exist per-each handler label // so our indices should always be unique. return Integer.compare(index, o.index); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/EnumNameRestorationTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.FieldInsnNode; import org.objectweb.asm.tree.InsnList; import org.objectweb.asm.tree.LdcInsnNode; import org.objectweb.asm.tree.MethodInsnNode; import org.objectweb.asm.tree.MethodNode; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.Collections; import java.util.IdentityHashMap; import java.util.Set; import static org.objectweb.asm.Opcodes.*; import static software.coley.recaf.util.AsmInsnUtil.getNextFollowGoto; import static software.coley.recaf.util.AsmInsnUtil.isConstIntValue; /** * A transformer that creates mappings to rename obfuscated enum constants that have been not properly obfuscated. * * @author Matt Coley */ @Dependent public class EnumNameRestorationTransformer implements JvmClassTransformer { private static final String VALUES_ARRAY_NAME = "$values"; @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { // Skip non-enum classes. if (!initialClassState.hasEnumModifier()) return; // Record mappings for enum constants where the static initializer // leaks their original names (assuming the code is name obfuscated). ClassNode node = context.getNode(bundle, initialClassState); String constDesc = 'L' + node.name + ";"; String valuesDesc = '[' + constDesc; for (MethodNode method : node.methods) { // Skip abstract methods. InsnList instructions = method.instructions; if (instructions == null) continue; // Skip any method that is not the static initializer. if (!method.name.equals("") || !method.desc.equals("()V")) continue; // Pattern to match for constants: // new ENUM_TYPE // dup // ldc "NAME_OF_CONSTANT" // iconst_0 // invokespecial ENUM_TYPE. (Ljava/lang/String;I)V (may have additional arguments, but first two should be consistent) // astore v0 (optional) // aload v0 (optional) // putstatic ENUM_TYPE.OBF_NAME_OF_CONSTANT LENUM_TYPE; // Pattern to match for $values array // invokestatic ENUM_TYPE.$values ()[LENUM_TYPE; // putstatic Example.OBF_NAME_OF_ARRAY [LENUM_TYPE; for (AbstractInsnNode instruction : instructions) { int op = instruction.getOpcode(); if (op == LDC) handleEnumConst(context, initialClassState, (LdcInsnNode) instruction, constDesc); else if (op == INVOKESTATIC) handleValuesArray(context, initialClassState, (MethodInsnNode) instruction, valuesDesc); } } } private void handleValuesArray(@Nonnull JvmTransformerContext context, @Nonnull JvmClassInfo initialClassState, @Nonnull MethodInsnNode invokeInsn, @Nonnull String valuesDesc) { String enumOwner = initialClassState.getName(); String invokeOwner = invokeInsn.owner; String invokeName = invokeInsn.name; String invokeDesc = invokeInsn.desc; Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); if (invokeOwner.equals(enumOwner) && invokeDesc.equals("()" + valuesDesc)) { AbstractInsnNode next = getNextFollowGoto(invokeInsn); while (next != null && next.getOpcode() != PUTSTATIC && visited.add(next)) next = getNextFollowGoto(next); if (next != null && next.getOpcode() == PUTSTATIC) { FieldInsnNode assignmentInsn = (FieldInsnNode) next; String fieldOwner = assignmentInsn.owner; String fieldName = assignmentInsn.name; String fieldDesc = assignmentInsn.desc; if (fieldOwner.equals(enumOwner) && fieldDesc.equals(valuesDesc) && !fieldName.equals(VALUES_ARRAY_NAME)) context.getMappings().addField(enumOwner, valuesDesc, fieldName, VALUES_ARRAY_NAME); } } } private static void handleEnumConst(@Nonnull JvmTransformerContext context, @Nonnull JvmClassInfo initialClassState, @Nonnull LdcInsnNode nameInsn, @Nonnull String constDesc) { if (!(nameInsn.cst instanceof String nameString) || !nameString.matches("\\w+")) return; AbstractInsnNode indexInsn = getNextFollowGoto(nameInsn); if (indexInsn == null || !isConstIntValue(indexInsn)) return; Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); AbstractInsnNode next = getNextFollowGoto(indexInsn); while (next != null && next.getOpcode() != PUTSTATIC && visited.add(next)) next = getNextFollowGoto(next); if (next != null && next.getOpcode() == PUTSTATIC) { FieldInsnNode assignmentInsn = (FieldInsnNode) next; String fieldName = assignmentInsn.name; String fieldDesc = assignmentInsn.desc; if (fieldDesc.equals(constDesc) && !fieldName.equals(nameString)) { FieldMember field = initialClassState.getDeclaredField(fieldName, fieldDesc); if (field != null && field.hasEnumModifier()) context.getMappings().addField(initialClassState.getName(), constDesc, fieldName, nameString); } } } @Nonnull @Override public String name() { return "Enum name restoration"; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/ExceptionCollectionTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import jakarta.inject.Inject; import org.objectweb.asm.ClassReader; import org.objectweb.asm.MethodVisitor; import org.objectweb.asm.Opcodes; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.properties.builtin.ThrowableProperty; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.services.inheritance.InheritanceGraphService; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.util.visitors.SkippingClassVisitor; import software.coley.recaf.util.visitors.SkippingMethodVisitor; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.HashSet; import java.util.Set; /** * A transformer that collects information about exceptions in the workspace. * * @author Matt Coley */ @Dependent public class ExceptionCollectionTransformer implements JvmClassTransformer, Opcodes { private final Set thrownExceptions = new HashSet<>(); private final InheritanceGraphService graphService; private InheritanceGraph inheritanceGraph; @Inject public ExceptionCollectionTransformer(@Nonnull InheritanceGraphService graphService) { this.graphService = graphService; // Base types which we assume are implicitly thrown. thrownExceptions.add("java/lang/Throwable"); thrownExceptions.add("java/lang/Exception"); } @Override public void setup(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace) { inheritanceGraph = graphService.getOrCreateInheritanceGraph(workspace); } @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { initialClassState.getClassReader().accept(new SkippingClassVisitor() { @Override public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { return new SkippingMethodVisitor() { @Override public void visitTypeInsn(int opcode, String type) { // Collect "new T" where "T" is a throwable type. if (opcode != NEW) return; ClassPathNode path = workspace.findClass(false, type); if (path == null) return; if (ThrowableProperty.get(path.getValue()) || inheritanceGraph.isAssignableFrom("java/lang/Throwable", type)) { // The constructed type is an exception type, // so we should add it and all parents to the known thrown types. ClassInfo exInfo = path.getValue(); while (thrownExceptions.add(exInfo.getName()) && exInfo.getSuperName() != null) { path = workspace.findClass(false, exInfo.getSuperName()); if (path == null) break; } } } }; } }, ClassReader.SKIP_FRAMES | ClassReader.SKIP_DEBUG); } @Nonnull @Override public String name() { return "Exception metadata collection"; } /** * @return Set of all exception types thrown in code defined in the workspace. */ @Nonnull public Set getThrownExceptions() { return thrownExceptions; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/FrameRemovingTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.FrameNode; import org.objectweb.asm.tree.InsnList; import org.objectweb.asm.tree.MethodNode; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; /** * A transformer that removes stack frames. * * @author Matt Coley */ @Dependent public class FrameRemovingTransformer implements JvmClassTransformer { @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { if (context.isNode(bundle, initialClassState)) { ClassNode node = context.getNode(bundle, initialClassState); for (MethodNode method : node.methods) { InsnList instructions = method.instructions; if (instructions != null) { for (int i = instructions.size() - 1; i > 0; i--) { AbstractInsnNode insn = instructions.get(i); if (insn instanceof FrameNode) instructions.remove(insn); } } } } else { ClassReader reader = new ClassReader(context.getBytecode(bundle, initialClassState)); ClassWriter writer = new ClassWriter(reader, 0); reader.accept(writer, ClassReader.SKIP_FRAMES); context.setBytecode(bundle, initialClassState, writer.toByteArray()); } context.setRecomputeFrames(initialClassState.getName()); } @Nonnull @Override public String name() { return "Stack frame removal"; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/GotoInliningTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import org.objectweb.asm.Opcodes; import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.InsnList; import org.objectweb.asm.tree.InsnNode; import org.objectweb.asm.tree.JumpInsnNode; import org.objectweb.asm.tree.LabelNode; import org.objectweb.asm.tree.LookupSwitchInsnNode; import org.objectweb.asm.tree.MethodNode; import org.objectweb.asm.tree.TableSwitchInsnNode; import org.objectweb.asm.tree.TryCatchBlockNode; import software.coley.recaf.RecafConstants; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.transform.ClassTransformer; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.Set; import static software.coley.recaf.util.AsmInsnUtil.*; /** * A transformer that inlines control flow of (redundant) {@link Opcodes#GOTO} instructions. * * @author Matt Coley */ @Dependent public class GotoInliningTransformer implements JvmClassTransformer { private static final int CATCH_VISIT_COUNT = 100; private static final boolean DO_WE_CARE_ABOUT_BACKWARDS_SWITCH_FLOW = false; @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { boolean dirty = false; String className = initialClassState.getName(); ClassNode node = context.getNode(bundle, initialClassState); for (int m = 0; m < node.methods.size(); m++) { MethodNode base = node.methods.get(m); // Skip if abstract. if (base.instructions == null) continue; // Because of the multiple "stages" we do, it's easier if we work on a copy of the method // and then write back our copy if we ended up making relevant changes. This way, if we // have changes from pre-processing, but there is no inlining work that gets done we can // throw away the changes and not worry about the changes accidentally being kept. MethodNode method = copyOf(base); InsnList instructions = method.instructions; // There are some obfuscators that put junk after the final 'return' instruction of methods. // // For example: // return // nop // dead code removed by ASM // athrow // // In this transformer, we ensure that removing blocks near the end of a method does not result in dangling code. // Without removing the dead code seen in the example, this transformer would see the 'athrow' and think it is // ok to move a block containing the 'return' somewhere else. However, the 'athrow' there is not valid because // there is nothing on the stack to throw. // // The simple fix is to do a dead-code removing pass before we run this transformer. context.pruneDeadCode(node, method); // Record where labels are visited from. Start with explicit control flow jumps. VisitCounters visitCounters = new VisitCounters(); for (int i = 0; i < instructions.size(); i++) { AbstractInsnNode insn = instructions.get(i); if (insn instanceof JumpInsnNode cast) { visitCounters.of(cast.label).addExplicitSource(cast); } else if (insn instanceof TableSwitchInsnNode cast) { visitCounters.of(cast.dflt).addExplicitSource(cast); cast.labels.forEach(l -> visitCounters.of(l).addExplicitSource(cast)); } else if (insn instanceof LookupSwitchInsnNode cast) { visitCounters.of(cast.dflt).addExplicitSource(cast); cast.labels.forEach(l -> visitCounters.of(l).addExplicitSource(cast)); } } // Next record which labels are try-catch boundaries. if (method.tryCatchBlocks != null) { for (TryCatchBlockNode tryCatch : method.tryCatchBlocks) { visitCounters.of(tryCatch.start).markTryStart(); visitCounters.of(tryCatch.end).markTryEnd(); visitCounters.of(tryCatch.handler).markTryHandler(); } } // Lastly record implicit flow into labels. // This just means if the code flows linearly from "A" into "B". for (int i = 0; i < instructions.size(); i++) { AbstractInsnNode insn = instructions.get(i); if (insn instanceof LabelNode targetLabel) { AbstractInsnNode prev = targetLabel.getPrevious(); // If the target label has no previous instruction then it must be the first label of the method. // Thus, it makes sense to say "yeah, we can flow here". if (prev == null) { visitCounters.of(targetLabel).markImplicitFlow(); continue; } // We want to see if we can naturally flow into this position. // Begin walking backwards linearly and see if we can end up at this target label. while (true) { // If we encounter an instruction that terminates linear flow we will stop walking backward. // This should imply that the target label cannot naturally be flowed into. if (isTerminalOrAlwaysTakeFlowControl(prev.getOpcode())) break; // If we encounter another label that is visited at least once while walking backwards // then the flow should continue from there to our target label. if (prev instanceof LabelNode prevLabel && visitCounters.of(prevLabel).isVisited()) { visitCounters.of(targetLabel).markImplicitFlow(); break; } // Step backwards. prev = prev.getPrevious(); // Same check that we had before the while loop. If we hit the start of the method, // then of course we can flow to here. if (prev == null) { visitCounters.of(targetLabel).markImplicitFlow(); break; } } } } // Check for super-simple goto instruction patterns that can be inlined easily. boolean localDirty = false; for (int i = 0; i < instructions.size(); i++) { AbstractInsnNode insn = instructions.get(i); // Skip any non-goto instruction. if (insn.getOpcode() != GOTO) continue; // If the goto target label is just the next instruction then we can replace // the goto with a nop. The dead code pass later on will clean these up. JumpInsnNode jin = (JumpInsnNode) insn; VisitCounter counter = visitCounters.of(jin.label); if (jin.label == jin.getNext() && !counter.isTryTarget()) { localDirty = true; instructions.set(jin, new InsnNode(NOP)); counter.removeExplicitSource(jin); counter.markImplicitFlow(); } } // Check for goto instruction patterns that require more care. // - Cannot result in creating end-of-method fall-through. // - Cannot inline a block starting with a label that is flowed to both explicitly and implicitly. // - Cannot inline a block that would break try-catch range contracts. for (int i = 0; i < instructions.size(); i++) { AbstractInsnNode insn = instructions.get(i); // Skip any non-goto instruction. if (insn.getOpcode() != GOTO) continue; // Skip any jump target labels that are visited more than once. JumpInsnNode jin = (JumpInsnNode) insn; if (visitCounters.of(jin.label).count() > 1) continue; // Attempt to re-arrange the code at the goto's destination to be inline here. doInline: { List block = new ArrayList<>(); AbstractInsnNode target = jin.label; while (target != null) { // Abort if we loop back around. if (target == jin) break doInline; // Abort if we see that relocating the GOTO destination's code would change the behavior // with try-catch blocks. if (method.tryCatchBlocks != null) { for (TryCatchBlockNode tryCatchBlock : method.tryCatchBlocks) { int gotoIndex = instructions.indexOf(jin); int targetIndex = instructions.indexOf(target); int tryStart = instructions.indexOf(tryCatchBlock.start); int tryEnd = instructions.indexOf(tryCatchBlock.end); // Skip if this would result in moving the targeted code inside a try-catch somewhere outside the try-catch. if (tryStart <= targetIndex && targetIndex < tryEnd) { if (tryStart > gotoIndex || gotoIndex >= tryEnd) break doInline; } // Skip if this would result in moving the code outside the try-catch into the try-catch if (tryStart <= gotoIndex && gotoIndex < tryEnd) { if (tryStart > targetIndex || targetIndex >= tryEnd) break doInline; } } } int targetOp = target.getOpcode(); // TODO: Maybe remove this? Need to do more research into when it complains. if (DO_WE_CARE_ABOUT_BACKWARDS_SWITCH_FLOW) { // There are some weird cases where you're not allowed to jump backwards in switch instructions, // so it's better to abort if we see them so that we do not move them around in an illegal way. if (targetOp == TABLESWITCH || targetOp == LOOKUPSWITCH) break doInline; } // Record this instruction as part of the block if it isn't metadata/junk. if (target.getType() != AbstractInsnNode.FRAME) block.add(target); // Break out of this while loop if the target instruction is the end of a method's control flow, // or an always-branch instruction like goto/switch. This marks the end of our block. // We will do some final checks to see if this block can be inlined. if (isGotoBlockTerminator(targetOp)) { // Check if inlining this would cause dangling code (no return at the end of the method) AbstractInsnNode next = getNextInsn(target); if (instructions.getLast() == next || next == null) { // This block is the code at the end of the method. // Check if the code before this block has terminal control flow // (to prevent creation of dangling code at the end of the method) AbstractInsnNode prevBeforeBlock = getPreviousInsn(jin.label); if (prevBeforeBlock != null && !isTerminalOrAlwaysTakeFlowControl(prevBeforeBlock.getOpcode())) break doInline; } // Check if the current target instruction isn't the initial goto destination label, // and the current target is a label that has been visited more than once by control flow // originating from outside of this block. for (AbstractInsnNode blockInsn : block) { int blockInsnOp = blockInsn.getOpcode(); if (blockInsn != jin.label && blockInsnOp == -1 && blockInsn instanceof LabelNode blockInsnLabel && visitCounters.of(blockInsnLabel).getExplicitFlowSourcesExcluding(block) > 1) break doInline; } break; } // Move forward. target = target.getNext(); } // Cut and paste those instructions into a temporary list. InsnList tempInsnList = new InsnList(); for (AbstractInsnNode blockInsn : block) { instructions.remove(blockInsn); tempInsnList.add(blockInsn); } // Insert the block after the GOTO, then remove the GOTO. instructions.insert(jin, tempInsnList); instructions.remove(jin); localDirty = true; // Since we removed the original goto instruction, remove it from the label's visit counter. visitCounters.of(jin.label).removeExplicitSource(jin); // Start over from the beginning. i = 0; } } if (localDirty) { // Do another dead code removal pass. context.pruneDeadCode(node, method); // Fix references to labels that no longer exist in local variable debug metadata. fixMissingVariableLabels(method); dirty = true; // Update the class with our transformed copy of the method. node.methods.set(m, method); } } if (dirty) { context.setRecomputeFrames(className); context.setNode(bundle, initialClassState, node); } } @Nonnull @Override public Set> dependencies() { return Collections.singleton(DeadCodeRemovingTransformer.class); } @Nonnull @Override public String name() { return "Goto inlining"; } /** * @param base * Method model to copy. * * @return Copy of method model. */ @Nonnull private static MethodNode copyOf(@Nonnull MethodNode base) { MethodNode copy = new MethodNode(RecafConstants.getAsmVersion(), base.access, base.name, base.desc, base.signature, base.exceptions.toArray(String[]::new)) { @Override public void visitFrame(int type, int numLocal, Object[] local, int numStack, Object[] stack) { // Skip frames } }; base.accept(copy); return copy; } /** * @param op * Instruction opcode. * * @return {@code true} when the opcode represents an instruction * that should mark the end of a {@code GOTO} destination block. */ private static boolean isGotoBlockTerminator(int op) { return isReturn(op) || op == GOTO || op == TABLESWITCH || op == LOOKUPSWITCH || op == ATHROW; } /** * Map of {@link LabelNode} to visit/flow data. */ private static class VisitCounters { private final Map visitCounters = new IdentityHashMap<>(); @Nonnull public VisitCounter of(@Nonnull LabelNode label) { return visitCounters.computeIfAbsent(label, VisitCounter::new); } } /** * Model of control flow interactions with a {@link LabelNode}. */ private static class VisitCounter { private final LabelNode label; private final Set explicitFlowSources = Collections.newSetFromMap(new IdentityHashMap<>()); private boolean implicitFlow; private boolean tryTarget; private VisitCounter(@Nonnull LabelNode label) { this.label = label; } public void addExplicitSource(@Nonnull AbstractInsnNode insn) { explicitFlowSources.add(insn); } public void removeExplicitSource(@Nonnull AbstractInsnNode insn) { explicitFlowSources.remove(insn); } public void markImplicitFlow() { implicitFlow = true; } public boolean isVisited() { if (!explicitFlowSources.isEmpty()) return true; return implicitFlow || tryTarget; } public boolean isImplicitFlow() { return implicitFlow; } public boolean isTryTarget() { return tryTarget; } public long getExplicitFlowSourcesExcluding(@Nonnull Collection block) { return explicitFlowSources.stream() .filter(insn -> !block.contains(insn)) .count(); } public int count() { return explicitFlowSources.size() + (implicitFlow ? 1 : 0) + (tryTarget ? 1 : 0); } public void markTryStart() { tryTarget = true; } public void markTryEnd() { tryTarget = true; } public void markTryHandler() { tryTarget = true; } @Override public String toString() { return "VisitCounter{" + "label=" + indexOf(label) + ", explicitFlowSources=" + explicitFlowSources.size() + ", implicitFlow=" + implicitFlow + ", tryTarget=" + tryTarget + '}'; } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/IllegalAnnotationRemovingTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.util.visitors.IllegalAnnotationRemovingVisitor; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; /** * A transformer that removes any invalid annotations from classes and any of their declared members. * * @author Matt Coley */ @Dependent public class IllegalAnnotationRemovingTransformer implements JvmClassTransformer { @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { // Adapt the class bytes by removing any illegal annotation. ClassReader reader = new ClassReader(context.getBytecode(bundle, initialClassState)); ClassWriter writer = new ClassWriter(reader, 0); IllegalAnnotationRemovingVisitor remover = new IllegalAnnotationRemovingVisitor(writer); reader.accept(remover, initialClassState.getClassReaderFlags()); // If the visitor did work, update the class. if (remover.hasDetectedIllegalAnnotations()) context.setBytecode(bundle, initialClassState, writer.toByteArray()); } @Nonnull @Override public String name() { return "Illegal annotation removal"; } @Override public boolean pruneAfterNoWork() { // Other transformers should not introduce junk annotations, // so once the work is done there is no need to re-process classes on following passes. return true; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/IllegalNameMappingTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.services.mapping.IntermediateMappings; import software.coley.recaf.services.mapping.gen.filter.IncludeKeywordNameFilter; import software.coley.recaf.services.mapping.gen.filter.IncludeNonAsciiNameFilter; import software.coley.recaf.services.mapping.gen.filter.IncludeNonJavaIdentifierNameFilter; import software.coley.recaf.services.mapping.gen.filter.IncludeWhitespaceNameFilter; import software.coley.recaf.services.mapping.gen.filter.NameGeneratorFilter; import software.coley.recaf.services.mapping.gen.naming.IncrementingNameGenerator; import software.coley.recaf.services.mapping.gen.naming.NameGenerator; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; /** * A transformer that renames classes and members that are not valid Java identifiers. * * @author Matt Coley */ @Dependent public class IllegalNameMappingTransformer implements JvmClassTransformer { private static final NameGeneratorFilter ILLEGAL_NAME_FILTER = new IncludeWhitespaceNameFilter(new IncludeNonAsciiNameFilter(new IncludeKeywordNameFilter(new IncludeNonJavaIdentifierNameFilter(null)))); private static final NameGenerator NAME_GENERATOR = new IncrementingNameGenerator(); @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { // Create mappings for classes and members with illegal names. // Anything that already has a name in the current mappings will be ignored. IntermediateMappings mappings = context.getMappings(); String ownerName = initialClassState.getName(); if (ILLEGAL_NAME_FILTER.shouldMapClass(initialClassState) && mappings.getMappedClassName(ownerName) == null) mappings.addClass(ownerName, NAME_GENERATOR.mapClass(initialClassState)); for (FieldMember field : initialClassState.getFields()) { String fieldDesc = field.getDescriptor(); String fieldName = field.getName(); if (ILLEGAL_NAME_FILTER.shouldMapField(initialClassState, field) && mappings.getMappedFieldName(ownerName, fieldDesc, fieldName) == null) mappings.addField(ownerName, fieldDesc, field.getName(), NAME_GENERATOR.mapField(initialClassState, field)); } for (MethodMember method : initialClassState.getMethods()) { String methodDesc = method.getDescriptor(); String methodName = method.getName(); if (ILLEGAL_NAME_FILTER.shouldMapMethod(initialClassState, method) && mappings.getMappedMethodName(ownerName, methodDesc, methodName) == null) mappings.addMethod(ownerName, methodDesc, method.getName(), NAME_GENERATOR.mapMethod(initialClassState, method)); } } @Nonnull @Override public String name() { return "Illegal name mapping"; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/IllegalSignatureRemovingTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.util.visitors.IllegalSignatureRemovingVisitor; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; /** * A transformer that removes any invalid signatures from classes and any of their declared members. * * @author Matt Coley */ @Dependent public class IllegalSignatureRemovingTransformer implements JvmClassTransformer { @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { // Adapt the class bytes by removing any illegal signature. ClassReader reader = new ClassReader(context.getBytecode(bundle, initialClassState)); ClassWriter writer = new ClassWriter(reader, 0); IllegalSignatureRemovingVisitor remover = new IllegalSignatureRemovingVisitor(writer); reader.accept(remover, initialClassState.getClassReaderFlags()); // If the visitor did work, update the class. if (remover.hasDetectedIllegalSignatures()) context.setBytecode(bundle, initialClassState, writer.toByteArray()); } @Nonnull @Override public String name() { return "Illegal signature removal"; } @Override public boolean pruneAfterNoWork() { // Other transformers should not introduce junk signatures, // so once the work is done there is no need to re-process classes on following passes. return true; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/IllegalVarargsRemovingTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; import org.objectweb.asm.Type; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.util.visitors.IllegalVarargsRemovingVisitor; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; /** * A transformer that removes any invalid use of varargs from methods. * * @author Matt Coley */ @Dependent public class IllegalVarargsRemovingTransformer implements JvmClassTransformer { @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { // First scan the model to see if we need to actually reparse and patch the bytecode. boolean hasInvalidVarargs = false; for (MethodMember method : initialClassState.getMethods()) { if (method.hasVarargsModifier()) { Type methodType = Type.getMethodType(method.getDescriptor()); Type[] argumentTypes = methodType.getArgumentTypes(); if (argumentTypes.length == 0 || argumentTypes[argumentTypes.length - 1].getSort() != Type.ARRAY) { hasInvalidVarargs = true; break; } } } // If we found an invalid use case, we'll do the work to remove it. if (hasInvalidVarargs) { ClassReader reader = new ClassReader(context.getBytecode(bundle, initialClassState)); ClassWriter writer = new ClassWriter(reader, 0); IllegalVarargsRemovingVisitor remover = new IllegalVarargsRemovingVisitor(writer); reader.accept(remover, initialClassState.getClassReaderFlags()); if (remover.hasDetectedIllegalVarargs()) // Should always occur given the circumstances context.setBytecode(bundle, initialClassState, writer.toByteArray()); } } @Nonnull @Override public String name() { return "Illegal varargs removal"; } @Override public boolean pruneAfterNoWork() { // Other transformers should not introduce junk varargs, // so once the work is done there is no need to re-process classes on following passes. return true; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/KotlinMetadataCollectionTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.Dependent; import org.objectweb.asm.AnnotationVisitor; import org.objectweb.asm.ClassReader; import org.objectweb.asm.Type; import regexodus.Pattern; import software.coley.recaf.RecafConstants; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.member.ClassMember; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.services.mapping.aggregate.AggregatedMappings; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.util.RegexUtil; import software.coley.recaf.util.kotlin.KotlinMetadata; import software.coley.recaf.util.kotlin.model.KtClass; import software.coley.recaf.util.kotlin.model.KtFunction; import software.coley.recaf.util.kotlin.model.KtProperty; import software.coley.recaf.util.kotlin.model.KtType; import software.coley.recaf.util.kotlin.model.KtVariable; import software.coley.recaf.util.visitors.SkippingClassVisitor; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.function.Function; /** * A transformer that collects kotlin metadata and offers utilities to process it. *

* For deobfuscating descriptors it is recommended to use the following methods in order: *

    *
  1. {@link #mapKtDescriptor(KtType)} / {@link #mapKtDescriptor(KtFunction)}
  2. *
  3. {@link #mapToJvm(String)}
  4. *
  5. {@link #reverseMapDescriptor(String)}
  6. *
* * @author Matt Coley */ @Dependent public class KotlinMetadataCollectionTransformer implements JvmClassTransformer { private final Map kotlinClassModels = new HashMap<>(); private AggregatedMappings kotlinClassMappings; @Override public void setup(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace) { kotlinClassMappings = new AggregatedMappings(workspace); } @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { String ownerName = initialClassState.getName(); // Extract metadata KtClass ktClass = KotlinMetadata.extractKtModel(initialClassState); if (ktClass == null) { // Check if there is @JvmName as a fallback. The annotation is similar to SourceFileAttribute. initialClassState.getClassReader().accept(new SkippingClassVisitor() { @Override public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) { if (!descriptor.equals("Lkotlin/jvm/JvmName;")) return null; return new AnnotationVisitor(RecafConstants.getAsmVersion()) { private static final Pattern NAME_PATTERN = RegexUtil.pattern("\\w{1, 50}"); @Override public void visit(String name, Object value) { if ("name".equals(name) && value instanceof String sourceName && NAME_PATTERN.matches(sourceName)) { kotlinClassMappings.addClass(ownerName, initialClassState.getPackageName() + '/' + sourceName); } } }; } }, ClassReader.SKIP_DEBUG | ClassReader.SKIP_CODE); return; } // The @MetaData class name is the full class descriptor. Add it as-is to the mappings. if (ktClass.getName() != null) kotlinClassMappings.addClass(ownerName, ktClass.getName()); kotlinClassModels.put(ownerName, ktClass); } /** * @param info * Class to find matching field within. * @param property * Kotlin property model. * * @return Matching field for the property model. */ @Nullable public FieldMember getField(@Nonnull ClassInfo info, @Nonnull KtProperty property) { String descriptor = mapKtDescriptor(property.getType()); List candidates = getCandidates(descriptor, info, ClassInfo::getFields); // No candidates found if (candidates.isEmpty()) return null; // Sole candidate found if (candidates.size() == 1) return candidates.getFirst(); return null; } /** * @param info * Class to find matching field within. * @param property * Kotlin property model. * * @return Matching getter method for the property model. */ @Nullable public MethodMember getFieldGetter(@Nonnull ClassInfo info, @Nonnull KtProperty property) { String descriptor = "()" + mapKtDescriptor(property.getType()); List candidates = getCandidates(descriptor, info, ClassInfo::getMethods); // No candidates found if (candidates.isEmpty()) return null; // Sole candidate found if (candidates.size() == 1) return candidates.getFirst(); return null; } /** * @param info * Class to find matching field within. * @param function * Kotlin function model. * * @return Matching method for the function model. */ @Nullable public MethodMember getMethod(@Nonnull ClassInfo info, @Nonnull KtFunction function) { String descriptor = mapKtDescriptor(function); List candidates = getCandidates(descriptor, info, ClassInfo::getMethods); // No candidates found if (candidates.isEmpty()) return null; // Sole candidate found if (candidates.size() == 1) return candidates.getFirst(); return null; } @Nonnull private List getCandidates(@Nonnull String descriptor, @Nonnull ClassInfo info, @Nonnull Function> memberLookup) { String descriptorMapped1 = reverseMapDescriptor(descriptor); String descriptorMapped2 = mapToJvm(descriptorMapped1); // Count candidates (members matching mapped descriptor) Iterable members = memberLookup.apply(info); List candidates = null; for (T member : members) { if (member.getName().charAt(0) == '<') continue; String memberDescriptor = member.getDescriptor(); if (memberDescriptor.equals(descriptorMapped1) || memberDescriptor.equals(descriptorMapped2)) { if (candidates == null) candidates = new ArrayList<>(4); candidates.add(member); } } return candidates == null ? Collections.emptyList() : candidates; } /** * Say you have some obfuscated {@code class c {...}} that has a {@code @Metadata} that * tells you {@code "c" == "FooService"}. This maps {@code c} in descriptors to {@code FooService}. * * @param descriptor * Some descriptor. * * @return Descriptor with reverse mappings applied from the collected kotlin metadata. */ @Nonnull public String reverseMapDescriptor(@Nonnull String descriptor) { return Objects.requireNonNull(kotlinClassMappings.applyReverseMappings(descriptor)); } /** * @param name * Class name. * * @return Kotlin class metadata for the class, if found. */ @Nullable public KtClass getKtClass(@Nonnull String name) { return kotlinClassModels.get(name); } /** * @param name * Class name. * * @return Kotlin class name within the metadata, if found. */ @Nullable public String getKtFallbackMapping(@Nonnull String name) { return kotlinClassMappings.getMappedClassName(name); } @Nonnull @Override public String name() { return "Kotlin metadata collection"; } /** * @param function * Kotlin function model. * * @return Descriptor of the function model. * * @see #reverseMapDescriptor(String) */ @Nonnull public static String mapKtDescriptor(@Nonnull KtFunction function) { StringBuilder sb = new StringBuilder("("); for (KtVariable parameter : function.getParameters()) sb.append(mapKtDescriptor(parameter.getType())); sb.append(')').append(mapKtDescriptor(function.getReturnType())); return sb.toString(); } /** * @param type * Kotlin type model. * * @return Descriptor of the type model. * * @see #reverseMapDescriptor(String) */ @Nonnull public static String mapKtDescriptor(@Nullable KtType type) { String descriptor = KtType.toDescriptor(type); // Special case handling for types that require knowledge of type arguments to map. // Any other cases will be handled later via 'mapToJvm'. if (descriptor.equals("Lkotlin/Array;")) { List arguments = Objects.requireNonNull(type).getArguments(); if (arguments != null) { descriptor = "[" + mapKtDescriptor(arguments.getFirst()); } } return descriptor; } /** * @param kotlinDescriptor * Descriptor containing Kotlin std-lib types. * * @return Descriptor with known std-lib type substitutions. * * @see #reverseMapDescriptor(String) */ @Nonnull public static String mapToJvm(@Nonnull String kotlinDescriptor) { if (kotlinDescriptor.charAt(0) == '(') { Type methodType = Type.getMethodType(kotlinDescriptor); StringBuilder sb = new StringBuilder("("); for (Type arg : methodType.getArgumentTypes()) sb.append(mapToJvm(arg.getDescriptor())); sb.append(')').append(mapToJvm(methodType.getReturnType().getDescriptor())); return sb.toString(); } return switch (kotlinDescriptor) { case "Lkotlin/Boolean;" -> "Z"; case "Lkotlin/BooleanArray;" -> "[Z"; case "Lkotlin/Byte;" -> "B"; case "Lkotlin/ByteArray;" -> "[B"; case "Lkotlin/UByte;", "Lkotlin/Int;", "Lkotlin/UInt;", "Lkotlin/UShort;" -> "I"; case "Lkotlin/UByteArray;", "Lkotlin/IntArray;", "Lkotlin/UIntArray;", "Lkotlin/UShortArray;" -> "[I"; case "Lkotlin/Char;" -> "C"; case "Lkotlin/CharArray;" -> "[C"; case "Lkotlin/Double;" -> "D"; case "Lkotlin/DoubleArray;" -> "[D"; case "Lkotlin/Float;" -> "F"; case "Lkotlin/FloatArray;" -> "[F"; case "Lkotlin/Long;", "Lkotlin/ULong;" -> "J"; case "Lkotlin/LongArray;", "Lkotlin/ULongArray;" -> "[J"; case "Lkotlin/Short;" -> "S"; case "Lkotlin/ShortArray;" -> "[S"; case "Lkotlin/Unit;" -> "V"; case "Lkotlin/Any;" -> "Ljava/lang/Object;"; // This one isn't a 1-to-1... case "Lkotlin/String;" -> "Ljava/lang/String;"; case "Lkotlin/Enum;" -> "Ljava/lang/Enum;"; case "Lkotlin/Comparable;" -> "Ljava/lang/Comparable;"; case "Lkotlin/Comparator;" -> "Ljava/lang/Comparator;"; // Reflection // Some things are inconsistently mapped (like KFunction1/KMutableProperty1), so we can't operate on those. case "Lkotlin/reflect/KClass;" -> "Ljava/lang/Class;"; // Some collections are directly mapped to Java's case "Lkotlin/collections/Collection;" -> "Ljava/util/Collection;"; case "Lkotlin/collections/Iterable;" -> "Ljava/util/Iterable;"; case "Lkotlin/collections/Iterator;", "Lkotlin/collections/MutableIterator;" -> "Ljava/util/Iterator;"; case "Lkotlin/collections/ListIterator;", "Lkotlin/collections/MutableListIterator;" -> "Ljava/util/ListIterator;"; case "Lkotlin/collections/List;", "Lkotlin/collections/MutableList;" -> "Ljava/util/List;"; case "Lkotlin/collections/Set;", "Lkotlin/collections/MutableSet;" -> "Ljava/util/Set;"; case "Lkotlin/collections/Map;", "Lkotlin/collections/MutableMap;" -> "Ljava/util/Map;"; // Some function types get migrated case "Lkotlin/Function0;" -> "Lkotlin/jvm/functions/Function0;"; case "Lkotlin/Function1;" -> "Lkotlin/jvm/functions/Function1;"; case "Lkotlin/Function2;" -> "Lkotlin/jvm/functions/Function2;"; case "Lkotlin/Function3;" -> "Lkotlin/jvm/functions/Function3;"; case "Lkotlin/Function4;" -> "Lkotlin/jvm/functions/Function4;"; case "Lkotlin/Function5;" -> "Lkotlin/jvm/functions/Function5;"; case "Lkotlin/Function6;" -> "Lkotlin/jvm/functions/Function6;"; case "Lkotlin/Function7;" -> "Lkotlin/jvm/functions/Function7;"; case "Lkotlin/Function8;" -> "Lkotlin/jvm/functions/Function8;"; case "Lkotlin/Function9;" -> "Lkotlin/jvm/functions/Function9;"; case "Lkotlin/Function10;" -> "Lkotlin/jvm/functions/Function10;"; case "Lkotlin/FunctionN;" -> "Lkotlin/jvm/functions/FunctionN;"; default -> kotlinDescriptor; }; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/KotlinNameRestorationTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.services.mapping.aggregate.AggregatedMappings; import software.coley.recaf.services.transform.ClassTransformer; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.util.StringUtil; import software.coley.recaf.util.kotlin.model.KtClass; import software.coley.recaf.util.kotlin.model.KtFunction; import software.coley.recaf.util.kotlin.model.KtProperty; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.Collections; import java.util.Set; /** * A transformer that renames classes and members based on Kotlin metadata. * * @author Matt Coley */ @Dependent public class KotlinNameRestorationTransformer implements JvmClassTransformer { @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { AggregatedMappings mappings = context.getMappings(); String ownerName = initialClassState.getName(); KotlinMetadataCollectionTransformer metadata = context.getJvmTransformer(KotlinMetadataCollectionTransformer.class); KtClass ktClass = metadata.getKtClass(ownerName); if (ktClass == null) { // No metadata model, but see if we were able to extract a name from some other kind of data from // our collection transformation pass. String mappedOwner = metadata.getKtFallbackMapping(ownerName); if (mappedOwner != null) mappings.addClass(ownerName, mappedOwner); return; } // Sadly, the kotlin meta-data is unordered, so we can only be sure about name mappings // when there are only EXACT descriptor matches. if (ktClass.getName() != null) mappings.addClass(ownerName, ktClass.getName()); for (KtProperty property : ktClass.getProperties()) { String propertyName = property.getName(); if (propertyName == null) continue; // Check for field match FieldMember field = metadata.getField(initialClassState, property); if (field != null) mappings.addField(ownerName, field.getDescriptor(), field.getName(), propertyName); // Check for getter match MethodMember method = metadata.getFieldGetter(initialClassState, property); if (method != null) { if (method.getName().equals(propertyName)) continue; if (!propertyName.startsWith("get") && !propertyName.startsWith("is") && !propertyName.startsWith("do")) propertyName = "get" + StringUtil.uppercaseFirstChar(propertyName); mappings.addMethod(ownerName, method.getDescriptor(), method.getName(), propertyName); } } for (KtFunction function : ktClass.getFunctions()) { if (function.getName() == null) continue; // Check for method match MethodMember method = metadata.getMethod(initialClassState, function); if (method != null) mappings.addMethod(ownerName, method.getDescriptor(), method.getName(), function.getName()); } } @Nonnull @Override public String name() { return "Kotlin name restoration"; } @Nonnull @Override public Set> dependencies() { return Collections.singleton(KotlinMetadataCollectionTransformer.class); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/LongAnnotationRemovingTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.annotation.Annotated; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.util.visitors.LongAnnotationRemovingVisitor; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; /** * A transformer that removes any invalid annotations from classes and any of their declared members. * * @author Matt Coley */ @Dependent public class LongAnnotationRemovingTransformer implements JvmClassTransformer { private static final int LONG_ANNO = 150; @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { // Adapt the class bytes by removing any stupidly long annotation. ClassReader reader = new ClassReader(context.getBytecode(bundle, initialClassState)); ClassWriter writer = new ClassWriter(reader, 0); LongAnnotationRemovingVisitor remover = new LongAnnotationRemovingVisitor(writer, LONG_ANNO); reader.accept(remover, initialClassState.getClassReaderFlags()); // If the visitor did work, update the class. if (remover.hasDetectedLongAnnotations()) context.setBytecode(bundle, initialClassState, writer.toByteArray()); } @Nonnull @Override public String name() { return "Long annotation removal"; } @Override public boolean pruneAfterNoWork() { // Other transformers should not introduce junk/long annotations, // so once the work is done there is no need to re-process classes on following passes. return true; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/LongExceptionRemovingTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.util.visitors.LongExceptionRemovingVisitor; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; /** * A transformer that removes any long/annoying exceptions from methods. * * @author Matt Coley */ @Dependent public class LongExceptionRemovingTransformer implements JvmClassTransformer { private static final int LONG_EXCEPTION = 150; @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { // Adapt the class bytes by removing any stupidly long annotation. // - Do not pass the reader as a writer parameter, MethodWriter.canCopyMethodAttributes breaks this ClassReader reader = new ClassReader(context.getBytecode(bundle, initialClassState)); ClassWriter writer = new ClassWriter(0); LongExceptionRemovingVisitor remover = new LongExceptionRemovingVisitor(writer, LONG_EXCEPTION); reader.accept(remover, initialClassState.getClassReaderFlags()); // If the visitor did work, update the class. if (remover.hasDetectedLongExceptions()) context.setBytecode(bundle, initialClassState, writer.toByteArray()); } @Nonnull @Override public String name() { return "Long exception removal"; } @Override public boolean pruneAfterNoWork() { // Other transformers should not introduce junk/long exceptions, // so once the work is done there is no need to re-process classes on following passes. return true; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/OpaqueConstantFoldingTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.Dependent; import jakarta.inject.Inject; import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.IincInsnNode; import org.objectweb.asm.tree.InsnList; import org.objectweb.asm.tree.InsnNode; import org.objectweb.asm.tree.LabelNode; import org.objectweb.asm.tree.LdcInsnNode; import org.objectweb.asm.tree.MethodInsnNode; import org.objectweb.asm.tree.MethodNode; import org.objectweb.asm.tree.VarInsnNode; import org.objectweb.asm.tree.analysis.Frame; import software.coley.collections.Lists; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.services.inheritance.InheritanceGraphService; import software.coley.recaf.services.transform.ClassTransformer; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.util.BlwUtil; import software.coley.recaf.util.analysis.ReFrame; import software.coley.recaf.util.analysis.eval.EvaluationResult; import software.coley.recaf.util.analysis.eval.EvaluationYieldResult; import software.coley.recaf.util.analysis.eval.Evaluator; import software.coley.recaf.util.analysis.eval.FieldCacheManager; import software.coley.recaf.util.analysis.value.DoubleValue; import software.coley.recaf.util.analysis.value.FloatValue; import software.coley.recaf.util.analysis.value.IntValue; import software.coley.recaf.util.analysis.value.LongValue; import software.coley.recaf.util.analysis.value.ReValue; import software.coley.recaf.util.analysis.value.StringValue; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; import static org.objectweb.asm.Opcodes.*; import static software.coley.recaf.util.AsmInsnUtil.*; /** * A transformer that folds sequences of computed values into constants. * * @author Matt Coley */ @Dependent public class OpaqueConstantFoldingTransformer implements JvmClassTransformer { private static final int[] ARG_1_SIZE = new int[255]; private static final int[] ARG_2_SIZE = new int[255]; private final InheritanceGraphService graphService; private InheritanceGraph inheritanceGraph; @Inject public OpaqueConstantFoldingTransformer(@Nonnull InheritanceGraphService graphService) { this.graphService = graphService; } @Override public void setup(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace) { inheritanceGraph = graphService.getOrCreateInheritanceGraph(workspace); } @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { boolean dirty = false; String className = initialClassState.getName(); ClassNode node = context.getNode(bundle, initialClassState); for (MethodNode method : node.methods) { InsnList instructions = method.instructions; if (instructions == null) continue; try { // This transformer runs in two passes. // // The first pass inlines various stack operations like DUP/SWAP/DUP_X. // By simplifying the stack we can mitigate some really annoying edge cases in the second pass. // // The second pass iteratively steps forwards and finds "operations" that act on stack values. // Where possible, if the result of the operation is known it will replace the operation instruction // and as many of the contributing instructions to it as possible. // // Any time either pass makes changes, it will generally replace instructions with NOP. // This is generally done over immediately removing them to reduce the risk of indexing errors. dirty |= pass1StackManipulation(context, node, method, instructions); dirty |= pass2SequenceFolding(context, node, method, instructions); // Now that we are done, we'll prune any NOP instructions if we made changes. if (dirty) { for (AbstractInsnNode insn : instructions.toArray()) { if (insn.getOpcode() == NOP) instructions.remove(insn); } } } catch (Throwable t) { throw new TransformationException("Error encountered when folding constants", t); } } if (dirty) context.setNode(bundle, initialClassState, node); } /** * Replaces stack manipulation instructions in the method. This may involve shifting the order of instructions * or placing copies of existing instructions in specific locations to achieve desired effects for operations * like {@code dup_x} instructions. * * @param context * Transformation context used to analyze methods for stack values. * @param node * Class defining the method. * @param method * The method to transform. * @param instructions * The instructions of the method. * * @return {@code true} when any stack operation was transformed. * * @throws TransformationException * When the method code couldn't be analyzed. */ private boolean pass1StackManipulation(@Nonnull JvmTransformerContext context, @Nonnull ClassNode node, @Nonnull MethodNode method, @Nonnull InsnList instructions) throws TransformationException { boolean dirty = false; int insertions = 0; Frame[] frames = context.analyze(inheritanceGraph, node, method); for (int i = 1; i < instructions.size() - 1; i++) { Frame frame = frames[i - insertions]; if (frame == null || frame.getStackSize() == 0) continue; AbstractInsnNode instruction = instructions.get(i); int opcode = instruction.getOpcode(); switch (opcode) { case DUP -> { if (getSlotsOccupied(frame) < 1) continue; ReValue top = frame.getStack(frame.getStackSize() - 1); AbstractInsnNode replacement = toInsn(top); if (replacement != null) { instructions.set(instruction, replacement); dirty = true; } else if (isSupportedValueProducer(instruction.getPrevious())) { instructions.set(instruction, instruction.getPrevious().clone(Collections.emptyMap())); dirty = true; } } case DUP2 -> { if (getSlotsOccupied(frame) < 2) continue; BinaryOperationArguments arguments = getBinaryOperationArguments(instruction.getPrevious(), DUP2); if (arguments != null && arguments.combinedIntermediates().isEmpty()) { if (arguments.argument2().sameAs(arguments.argument1())) { // Arguments are same since input is wide (long/double) AbstractInsnNode arg = arguments.argument2().insn(); if (isSupportedValueProducer(arg)) { instructions.set(instruction, arg.clone(Collections.emptyMap())); dirty = true; } } else { // Two separate arguments AbstractInsnNode arg1 = arguments.argument1().insn(); AbstractInsnNode arg2 = arguments.argument2().insn(); instructions.insert(instruction, arg2.clone(Collections.emptyMap())); instructions.set(instruction, arg1.clone(Collections.emptyMap())); insertions += 1; dirty = true; } } } case DUP_X1 -> { if (getSlotsOccupied(frame) < 2) continue; BinaryOperationArguments arguments = getBinaryOperationArguments(instruction.getPrevious(), DUP2_X1); if (arguments != null && arguments.combinedIntermediates().isEmpty()) { instructions.insertBefore(arguments.argument1().insn(), arguments.argument2().insn().clone(Collections.emptyMap())); instructions.set(instruction, new InsnNode(NOP)); insertions += 1; dirty = true; } } case DUP_X2 -> { if (getSlotsOccupied(frame) < 3) continue; BinaryOperationArguments arguments = getBinaryOperationArguments(instruction.getPrevious(), DUP2_X1); if (arguments != null && arguments.combinedIntermediates().isEmpty()) { Argument prior = collectArgument(arguments.argument1().insn().getPrevious()); if (prior == null) continue; instructions.insertBefore(prior.insn(), arguments.argument2().insn().clone(Collections.emptyMap())); instructions.set(instruction, new InsnNode(NOP)); insertions += 1; dirty = true; } } case DUP2_X1 -> { if (getSlotsOccupied(frame) < 2) continue; BinaryOperationArguments arguments = getBinaryOperationArguments(instruction.getPrevious(), DUP2_X1); if (arguments == null || !arguments.combinedIntermediates().isEmpty()) continue; Argument prior = collectArgument(arguments.argument1().insn().getPrevious()); if (prior == null) continue; AbstractInsnNode target = prior.insn(); if (arguments.argument2().sameAs(arguments.argument1())) { // Arguments are same since input is wide (long/double) AbstractInsnNode arg = arguments.argument2().insn(); if (isSupportedValueProducer(arg)) { instructions.insertBefore(target, arg.clone(Collections.emptyMap())); instructions.set(instruction, new InsnNode(NOP)); insertions += 1; dirty = true; } } else { // Two separate arguments AbstractInsnNode arg1 = arguments.argument1().insn(); AbstractInsnNode arg2 = arguments.argument2().insn(); instructions.insertBefore(target, arg1.clone(Collections.emptyMap())); instructions.insertBefore(target, arg2.clone(Collections.emptyMap())); instructions.set(instruction, new InsnNode(NOP)); insertions += 2; dirty = true; } } case DUP2_X2 -> { if (getSlotsOccupied(frame) < 2) continue; BinaryOperationArguments arguments = getBinaryOperationArguments(instruction.getPrevious(), DUP2_X1); if (arguments == null || !arguments.combinedIntermediates().isEmpty()) continue; BinaryOperationArguments prior = getBinaryOperationArguments(arguments.argument1().insn().getPrevious(), DUP2_X1); if (prior == null || !prior.combinedIntermediates().isEmpty()) continue; AbstractInsnNode target = prior.argument1().insn(); if (arguments.argument2().sameAs(arguments.argument1())) { // Arguments are same since input is wide (long/double) AbstractInsnNode arg = arguments.argument2().insn(); if (isSupportedValueProducer(arg)) { instructions.insertBefore(target, arg.clone(Collections.emptyMap())); instructions.set(instruction, new InsnNode(NOP)); insertions += 1; dirty = true; } } else { // Two separate arguments AbstractInsnNode arg1 = arguments.argument1().insn(); AbstractInsnNode arg2 = arguments.argument2().insn(); instructions.insertBefore(target, arg1.clone(Collections.emptyMap())); instructions.insertBefore(target, arg2.clone(Collections.emptyMap())); instructions.set(instruction, new InsnNode(NOP)); insertions += 2; dirty = true; } } case POP -> { if (getSlotsOccupied(frame) < 1) continue; Argument argument = collectArgument(instruction.getPrevious()); if (argument != null && argument.intermediates().isEmpty()) { instructions.set(instruction, new InsnNode(NOP)); argument.replaceInsn(instructions); dirty = true; } } case POP2 -> { if (getSlotsOccupied(frame) < 2) continue; BinaryOperationArguments arguments = getBinaryOperationArguments(instruction.getPrevious(), POP2); if (arguments != null && arguments.combinedIntermediates().isEmpty()) { instructions.set(instruction, new InsnNode(NOP)); arguments.replaceBinOp(instructions); dirty = true; } } case SWAP -> { if (getSlotsOccupied(frame) < 2) continue; BinaryOperationArguments arguments = getBinaryOperationArguments(instruction.getPrevious(), POP2); if (arguments != null && arguments.combinedIntermediates().isEmpty()) { instructions.remove(arguments.argument1().insn()); instructions.insert(arguments.argument2().insn(), arguments.argument1().insn()); instructions.set(instruction, new InsnNode(NOP)); dirty = true; } } } } return dirty; } /** * Detects sequences of instructions that are passed to an "operation" like {@code iadd/dmul/fcml/etc}. * Once a sequence is validated such that the inputs are aligned to the expected stack state of the operation inputs * the entire sequence is replaced with the resulting pushed stack value. * * @param context * Transformation context used to analyze methods for stack values. * @param node * Class defining the method. * @param method * The method to transform. * @param instructions * The instructions of the method. * * @return {@code true} when any stack operation was transformed. * * @throws TransformationException * When the method code couldn't be analyzed. */ private boolean pass2SequenceFolding(@Nonnull JvmTransformerContext context, @Nonnull ClassNode node, @Nonnull MethodNode method, @Nonnull InsnList instructions) throws TransformationException { boolean dirty = false; List sequence = new ArrayList<>(); Frame[] frames = context.analyze(inheritanceGraph, node, method); int endIndex = instructions.size() - 1; int unknownState = -1; for (int i = 1; i < endIndex; i++) { AbstractInsnNode instruction = instructions.get(i); int opcode = instruction.getOpcode(); // Iterate until we find an instruction that consumes values off the stack as part of an "operation". int sizeConsumed = getSizeConsumed(instruction); if (sizeConsumed == 0 || (opcode >= POP && opcode <= DUP2_X2)) continue; // Return instructions consume values off the stack but unlike operations do not produce an outcome. boolean isReturn = isReturn(opcode) && opcode != RETURN; // Grab the current and next frame for later. We want to pull values from these to determine // if operations on constant inputs can be inlined. // However, a "return" isn't an operation, so we have an edge case handling those. Frame frame = frames[i]; if (frame == null) continue; Frame nextFrame = frames[i + 1]; if ((nextFrame == null || nextFrame.getStackSize() <= 0) && !isReturn) continue; // Walk backwards from this point and try and find a sequence of instructions that // will create the expected stack state we see for this operation instruction. boolean validSequence = true; int netStackChange = 0; int j = i; sequence.clear(); Map sequenceVarWrites = new HashMap<>(); while (j >= 0) { AbstractInsnNode insn = instructions.get(j); int insnOp = insn.getOpcode(); if (insnOp != NOP && insnOp != -1) // Skip adding NOP/Labels sequence.add(insn); // Abort if we've walked backwards into instructions where we observed an unknown stack state. if (j < unknownState) { // Move the unknown state forward since up to this point the stack is unbalanced // and thus this point relies on the prior point. unknownState = i; validSequence = false; break; } // Abort if we observe control flow. Both outbound and inbound breaks sequences. // If there is obfuscated control flow that is redundant use a control flow flattening transformer first. if (isFlowControl(insn)) { validSequence = false; break; } if (insn.getType() == AbstractInsnNode.LABEL && hasInboundFlowReferences(method, Collections.singletonList(insn))) { validSequence = false; break; } // Record variable side effects. // Because j steps backwards the first encountered write will be the only thing we need to ensure // is kept after folding the sequence. Frame jframe = frames[j]; if (isVarStore(insnOp) && insn instanceof VarInsnNode vin) { int index = vin.var; ReValue stack = frame.getStack(frame.getStackSize() - 1); if (!stack.hasKnownValue()) break; sequenceVarWrites.putIfAbsent(index, stack); } else if (insn instanceof IincInsnNode iinc) { int index = iinc.var; ReValue local = frame.getLocal(index); if (!local.hasKnownValue() || !(local instanceof IntValue intLocal)) break; sequenceVarWrites.putIfAbsent(index, intLocal.add(iinc.incr)); } // Update the net stack size change. int stackDiff = computeInstructionStackDifference(frames, j, insn); netStackChange += stackDiff; // Step backwards. j--; // If we see the net stack change is positive, our sequence is "done". if (netStackChange >= 1) break; } // Doing 'List.add' + 'reverse' is faster than 'List.addFirst' on large inputs. Collections.reverse(sequence); // Skip if the completed sequence isn't a viable candidate for folding. // - Explicitly marked as invalid // - Too small // - The sequence isn't balanced, or requires a larger scope to include all "contributing" instructions if (!validSequence || sequence.size() < 2 || shouldContinueSequence(sequence)) continue; // Additionally if the sequence does NOT end with 'xreturn' then it should // have a positive stack effect (the final operation result should push a value). if (netStackChange < (isReturn ? 0 : 1)) continue; // Keep the return instruction in the sequence. if (isReturn && isReturn(sequence.getLast())) { sequence.removeLast(); // Removing the return can put us under the limit. In this case, there is nothing to fold. // There is just a value and then the return. if (sequence.size() < 2) continue; } // Replace the operation with a constant value, or simplified instruction pattern. ReValue topValue = isReturn ? frame.getStack(frame.getStackSize() - 1) : nextFrame.getStack(nextFrame.getStackSize() - 1); // In some cases where the next instruction is a label targeted by backwards jumps from dummy/dead code // the analyzer can get fooled into merging an unknown state into something that should be known. // When this happens we can evaluate our sequence and see what the result should be. if (!isReturn && !topValue.hasKnownValue() && isLabel(sequence.getLast().getNext())) topValue = evaluateTopFromSequence(context, method, sequence, topValue, frames, j); // Handle replacing the sequence. AbstractInsnNode replacement = toInsn(topValue); if (replacement != null) { // If we have a replacement, remove all instructions in the sequence and replace the // operation instruction with one that pushes a constant value of the result in its place. for (AbstractInsnNode item : sequence) instructions.set(item, new InsnNode(NOP)); if (isReturn) { // We know the sequence size must be >= 2, so the instruction before // the return should have been replaced with a nop, and is safe to replace // with our constant. AbstractInsnNode old = instructions.get(i - 1); instructions.set(old, replacement); } else { instructions.set(instructions.get(i), replacement); // Insert variable writes to ensure their states are not affected by our inlining. sequenceVarWrites.forEach((index, value) -> { AbstractInsnNode varReplacement = toInsn(value); VarInsnNode varStore = createVarStore(index, Objects.requireNonNull(value.type(), "Missing var type")); instructions.insertBefore(replacement, varReplacement); instructions.insertBefore(replacement, varStore); }); i += sequenceVarWrites.size() * 2; } dirty = true; } else { // If we don't have a replacement (since the end state cannot be resolved) see if we can at least // fold redundant operations like "x = x * 1". if (foldRedundantOperations(instructions, instruction, frame)) { dirty = true; } else { int stackSize = frame.getStackSize(); for (int s = 0; s < stackSize; s++) { ReValue stack = frame.getStack(s); if (!stack.hasKnownValue()) { unknownState = i; break; } } } } } return dirty; } /** * Attempts to evaluate the given sequence of instructions to find the resulting value. * * @param context * Transformer context. * @param method * Method hosting the sequence of instructions. * @param sequence * Sequence of instructions to evaluate. * @param topValue * The existing top value that has an unknown value. * @param frames * The method stack frames. * @param sequenceStartIndex * The stack frame index where the sequence begins at. * * @return Top stack value after executing the given sequence of instructions. */ @Nonnull private ReValue evaluateTopFromSequence(@Nonnull JvmTransformerContext context, @Nonnull MethodNode method, @Nonnull List sequence, @Nonnull ReValue topValue, @Nonnull Frame[] frames, int sequenceStartIndex) { // Need to wrap a copy of the instructions in its own InsnList // so that instructions have 'getNext()' and 'getPrevious()' set properly. Map clonedLabels = Collections.emptyMap(); InsnList block = new InsnList(); for (AbstractInsnNode insn : sequence) block.add(insn.clone(clonedLabels)); // Setup evaluator. We generally only support linear folding, so having the execution step limit // match the sequence length with a little leeway should be alright. final int maxSteps = sequence.size() + 10; ReFrame initialBlockFrame = (ReFrame) frames[Math.max(0, sequenceStartIndex)]; Evaluator evaluator = new Evaluator(context.getWorkspace(), context.newInterpreter(inheritanceGraph), new FieldCacheManager(), maxSteps, false); // Evaluate the sequence and return the result. // If evaluation fails, return the original unknown top value. EvaluationResult result = evaluator.evaluateBlock(block, initialBlockFrame, method.access); if (result instanceof EvaluationYieldResult(ReValue value)) { if (Objects.equals(value.type(), topValue.type())) // Sanity check return value; } // Evaluation failed, this is to be expected as some cases cannot always be evaluated. return topValue; } /** * @param instructions * Instructions to operate on. * @param instruction * The instruction to check for being a redundant operation. * @param frame * The stackframe at the instruction position. * * @return {@code true} when the instruction was a redundant operation that has been folded. Otherwise {@code false}. */ private static boolean foldRedundantOperations(@Nonnull InsnList instructions, @Nonnull AbstractInsnNode instruction, @Nonnull Frame frame) { // Skip if this isn't an operation we can support if (frame.getStackSize() < 2) return false; // We don't know the result of the operation. But if it is something we know is redundant // we will want to remove it anyways. For instance: // x * 1 = x // x / 1 = x // x + 0 = x // x | 0 = x // x & -1 = x // x ^ 0 = x // x << 0 = x // x >> 0 = x // x >>> 0 = x ReValue top = frame.getStack(frame.getStackSize() - 1); ReValue topM1 = frame.getStack(frame.getStackSize() - 2); int opcode = instruction.getOpcode(); int targetValue = switch (opcode) { case IAND, LAND -> -1; case IMUL, FMUL, DMUL, LMUL, IDIV, FDIV, DDIV, LDIV -> 1; case IADD, FADD, DADD, LADD, IOR, LOR, IXOR, LXOR, ISHL, ISHR, IUSHR, LSHL, LSHR, LUSHR -> 0; default -> 25565; }; // Skip if not an operation we can simplify. if (targetValue == 25565) return false; if (ReValue.isPrimitiveEqualTo(top, targetValue)) { // Scan for the instructions that provide the argument values for the current instruction/binary operation. // - Start with the instruction before this one as a potential provider for the 2nd argument (right value in an operation) BinaryOperationArguments arguments = getBinaryOperationArguments(instruction.getPrevious(), opcode); if (arguments == null) return false; // Remove redundant operation + top/right value provider. instructions.set(instruction, new InsnNode(NOP)); instructions.set(arguments.argument2().insn(), new InsnNode(NOP)); for (AbstractInsnNode intermediate : arguments.argument2().intermediates()) instructions.set(intermediate, new InsnNode(NOP)); return true; } else if (ReValue.isPrimitiveEqualTo(topM1, targetValue)) { // Scan for the instructions that provide the argument values for the current instruction/binary operation. // - Start with the instruction before this one as a potential provider for the 2nd argument (right value in an operation) BinaryOperationArguments arguments = getBinaryOperationArguments(instruction.getPrevious(), opcode); if (arguments == null) return false; // Remove redundant operation + top-1/left value provider. instructions.set(instruction, new InsnNode(NOP)); instructions.set(arguments.argument1().insn(), new InsnNode(NOP)); for (AbstractInsnNode intermediate : arguments.argument1().intermediates()) instructions.set(intermediate, new InsnNode(NOP)); return true; } return false; } /** * @param frames * Method stack frames. * @param i * Index in the stack frames of the given instruction. * @param insn * The instruction to evaluate for stack size differences after execution. * * @return Stack size difference after execution. */ private static int computeInstructionStackDifference(@Nonnull Frame[] frames, int i, @Nonnull AbstractInsnNode insn) { // Ideally we just check the size difference between this frame and the next frame. // Not all frames exist in the array, as dead code gets skipped by ASM's analyzer. Frame thisFrame = frames[i]; Frame nextFrame = frames[i + 1]; if (thisFrame != null && nextFrame != null) { int jsize = thisFrame.getStackSize(); int jsizeNext = nextFrame.getStackSize(); return jsizeNext - jsize; } // This is our fallback. These utility methods are not ideal because they follow the JVM spec. // I know, that sounds ridiculous. But ASM's analyzer treats long/double as a single slot. // These size methods will treat long/double as two slots. The discrepancy can lead to us not // properly evaluating sequence lengths. This generally shouldn't ever be an issue unless we're // looking at dead code regions (which make the frame null checks above fail). int consumed = getSizeConsumed(insn); int produced = getSizeProduced(insn); return produced - consumed; } /** * Check if the given sequence is unbalanced, or is prefixed with an instruction that implies * more instructions should be included for a "full scope" of "contributing" instructions. * * @param sequence * Instruction sequence. * * @return {@code true} when the instruction sequence should continue expanding backwards. */ private static boolean shouldContinueSequence(@Nonnull List sequence) { int stackDiff = 0; int consumed = 0; int produced = 0; int netStackChange = 0; for (AbstractInsnNode seq : sequence) { // DUP operations operate on values on the stack. While the most simple DUP case is fine, any other variant // such as DUP2, DUP_X, etc. have edge cases which mean we cannot have a 100% foolproof/isolated sequence. // These require us to continue scanning backwards to expand the sequence. int op = seq.getOpcode(); if ((op == DUP && netStackChange < 1) || (op == DUP_X1 && netStackChange < 2) || (op == DUP_X2 && netStackChange < 3) || (op == DUP2 && netStackChange < 2) || (op == DUP2_X1 && netStackChange < 3) || (op == DUP2_X2 && netStackChange < 4) || (op == SWAP && netStackChange < 2) || (op == POP && netStackChange < 1) || (op == POP2 && netStackChange < 2)) { return true; } // Get stack change for this instruction in the sequence. consumed = getSizeConsumed(seq); produced = getSizeProduced(seq); // If we ever see the stack size in this sequence go negative, then it // cannot be treated as an "isolated" sequence. It implies that there is a reliance on a larger // stack size in the current sequence scope. We can't really "recover" this scope at this point. // However, if we create a new scope starting at a later instruction its possible it will give us // a larger scoped sequence which will include enough instructions to prevent this from occurring. if (consumed > netStackChange) return true; // Update net stack change. stackDiff = produced - consumed; netStackChange += stackDiff; } return netStackChange > consumed || netStackChange != produced; } @Nonnull @Override public String name() { return "Opaque constant folding"; } @Nonnull @Override public Set> recommendedPredecessors() { // Basic goto obf will prevent this transformer from handling "obvious" cases. return Collections.singleton(GotoInliningTransformer.class); } /** * Check if the instruction is responsible for providing some value we can possibly fold. * This method doesn't tell us if the value is known though. The next frame after this * instruction should have the provided value on the stack top. * * @param insn * Instruction to check. * * @return {@code true} when the instruction will produce a single value. */ protected static boolean isSupportedValueProducer(@Nonnull AbstractInsnNode insn) { // Skip if this instruction consumes a value off the stack. if (getSizeConsumed(insn) > 0) return false; // The following cases are supported: // - constants // - variable loads (context will determine if value in variable is constant at the given position) // - static field gets (context will determine if value in field is constant/known) // - static method calls with 0 args (context will determine if returned value of method is constant/known) int op = insn.getOpcode(); if (isConstValue(op)) return true; if (op >= ILOAD && op <= ALOAD) return true; if (op == GETSTATIC) return true; return op == INVOKESTATIC && insn instanceof MethodInsnNode min && min.desc.startsWith("()") && !min.desc.endsWith(")V"); } /** * @param value * Value to convert. * * @return Instruction representing the value, * or {@code null} if we don't/can't provide a mapping for the value content. */ @Nullable @SuppressWarnings("OptionalGetWithoutIsPresent") public static AbstractInsnNode toInsn(@Nonnull ReValue value) { // Skip if value is not known. if (!value.hasKnownValue()) return null; // Map known value types to constant value instructions. return switch (value) { case IntValue intValue -> intToInsn(intValue.value().getAsInt()); case FloatValue floatValue -> floatToInsn((float) floatValue.value().getAsDouble()); case DoubleValue doubleValue -> doubleToInsn(doubleValue.value().getAsDouble()); case LongValue longValue -> longToInsn(longValue.value().getAsLong()); case StringValue stringValue -> new LdcInsnNode(stringValue.getText().get()); default -> null; }; } /** * @param frame * Frame to count true stack size of (in terms of occupied slots) * * @return Number of stack slots occupied in the frame. */ private static int getSlotsOccupied(@Nonnull Frame frame) { int valueCount = frame.getStackSize(); int slots = 0; for (int i = 0; i < valueCount; i++) { ReValue value = frame.getStack(i); slots += value.getSize(); } return slots; } /** * This is essentially {@link #collectArgument(AbstractInsnNode)} but run twice, then wrapped up in a box. * The main difference is edge case handling for wide types and some sanity checks for edge cases applicable * only to cases where there are two arguments rather than just one. * * @param insnBeforeOp * Starting instruction representing a {@link #isSupportedValueProducer(AbstractInsnNode) value producer} * to an operation instruction (like an {@code iconst_1} as part of an {@code iadd} operation). * @param binOperationOpcode * The opcode for the operation instruction. Generally something like {@code iadd}, {@code dmul}, etc. * Used to determine how to treat arguments in some wide-type edge cases. * * @return Wrapper containing the arguments (and their instructions) if found. Otherwise {@code null}. */ @Nullable public static BinaryOperationArguments getBinaryOperationArguments(@Nonnull AbstractInsnNode insnBeforeOp, int binOperationOpcode) { // Get instruction of the top stack's contributing instruction. Argument argument2 = collectArgument(insnBeforeOp); if (argument2 == null) return null; // Get instruction of the 2nd-to-top stack's contributing instruction. // In some cases this may be the same value as the instruction we grabbed above. // Consider the case: // iconst_2 // dup2 // iadd // When we see "iadd" has arguments "dup2" it will satisfy both values in the addition. Argument argument1; if (argument2.providesBinaryOpValuesFor(binOperationOpcode)) { // If the instruction before produces a larger value than required we have // encountered a case that follows the example case above (likely a dup2). argument1 = argument2; } else { argument1 = collectArgument(argument2.insn().getPrevious()); // If we didn't find a value for argument 1, we cannot handle this binary argument. if (argument1 == null) return null; // If argument 1 was found, but is too wide (a double or dup2) for the binary argument considering // that we already have argument 2 resolved, then we also cannot handle this binary argument. // // Example: // sipush 20 // sipush 10 // dup2 <---- Pushes [20, 10] onto stack, resulting in [20, 10, 20, 10] // sipush -10 // swap <---- If we are here, and want to see what instructions provide "arguments" // ... then the "dup2" provides [20, 10] on the stack, while we only operate on [10]. // ... This makes it so that we can't correctly say "dup2" is 100% responsible for operands // ... in "swap" because it also produces "20" which isn't an operand for our "swap. if (argument1.providesBinaryOpValuesFor(binOperationOpcode)) return null; } // If we saw an odd number of "swap" before we got (arg2) then we want to swap the references. int swapCount = (int) argument2.intermediates().stream() .filter(i -> i.getOpcode() == SWAP) .count(); if (swapCount % 2 == 1) { Argument temp = argument1; argument1 = argument2; argument2 = temp; } // If we have recorded intermediate instructions that result in stack consumption // we need to remove the instructions they have consumed. // Track any intermediate instructions between the operation instruction // and the first argument instruction (arg2). List combinedIntermediates = new ArrayList<>(argument1.getCombinedIntermediates(argument2)); if (!canConsumeAccumulatedStackConsumption(argument1, argument2, combinedIntermediates)) return null; return new BinaryOperationArguments(argument2, argument1, combinedIntermediates); } /** * Starting with the provided instruction (inclusive), we walk backwards until a valid * {@link #isSupportedValueProducer(AbstractInsnNode) value producer} is found. There are certain * instructions which we will support as intermediates between the starting point and the final chosen instruction. * Intermediate instructions are generally stack manipulations which we want to remove as they are between the * actual instruction providing the value and the place where the value is used. * * @param insnBeforeOp * Starting instruction representing a {@link #isSupportedValueProducer(AbstractInsnNode) value producer} * to an operation instruction (like an {@code iconst_1} as part of an {@code ineg} operation). * * @return Wrapper containing the instruction if it is a value producer was found. Otherwise {@code null}. */ @Nullable public static Argument collectArgument(@Nullable AbstractInsnNode insnBeforeOp) { if (insnBeforeOp == null) return null; List intermediates = null; int intermediateStackConsumption = 0; while (insnBeforeOp != null) { int argumentOp = insnBeforeOp.getOpcode(); if (argumentOp == NOP) { insnBeforeOp = insnBeforeOp.getPrevious(); } else if (argumentOp == SWAP || argumentOp == POP || argumentOp == POP2) { // We already know the values in our frame, so these intermediate instructions // between the operation instruction and the instructions pushing those values // onto the stack can be recorded for removal. if (intermediates == null) intermediates = new ArrayList<>(); intermediates.add(insnBeforeOp); intermediateStackConsumption += getSizeConsumed(insnBeforeOp); insnBeforeOp = insnBeforeOp.getPrevious(); } else { break; } } if (insnBeforeOp == null || !isSupportedValueProducer(insnBeforeOp)) return null; return new Argument(insnBeforeOp, Objects.requireNonNullElse(intermediates, Collections.emptyList()), intermediateStackConsumption); } private static boolean canConsumeAccumulatedStackConsumption(@Nonnull Argument argument1, @Nonnull Argument argument2, @Nonnull List intermediates) { // The first argument (provides value beneath the 2nd argument on the stack) is where // our backwards search (exclusive) for instructions will begin. AbstractInsnNode insn = argument1.insn(); // Combine the stack consumption of both arguments and begin consumption. int intermediateStackConsumption = argument1.getCombinedStackConsumption(argument2); return canConsumeAccumulatedStackConsumption(intermediateStackConsumption, intermediates, insn); } private static boolean canConsumeAccumulatedStackConsumption(int intermediateStackConsumption, @Nonnull List intermediates, @Nonnull AbstractInsnNode start) { // If we have recorded intermediate instructions that result in stack consumption // we need to remove the instructions they have consumed. To do this, we will add them // to the intermediate instruction list. AbstractInsnNode insn = start; while (intermediateStackConsumption > 0) { insn = insn.getPrevious(); if (insn == null) break; if (insn.getOpcode() == NOP) continue; if (isSupportedValueProducer(insn)) { intermediates.add(insn); intermediateStackConsumption -= getSizeProduced(insn); } else { // We don't know how to handle this instruction, bail out. return false; } } return intermediateStackConsumption == 0; } static { Arrays.fill(ARG_1_SIZE, -1); Arrays.fill(ARG_2_SIZE, -1); ARG_1_SIZE[IADD] = 1; ARG_1_SIZE[FADD] = 1; ARG_1_SIZE[ISUB] = 1; ARG_1_SIZE[FSUB] = 1; ARG_1_SIZE[IMUL] = 1; ARG_1_SIZE[FMUL] = 1; ARG_1_SIZE[IDIV] = 1; ARG_1_SIZE[FDIV] = 1; ARG_1_SIZE[IREM] = 1; ARG_1_SIZE[FREM] = 1; ARG_1_SIZE[ISHL] = 1; ARG_1_SIZE[ISHR] = 1; ARG_1_SIZE[IUSHR] = 1; ARG_1_SIZE[IAND] = 1; ARG_1_SIZE[IXOR] = 1; ARG_1_SIZE[IOR] = 1; ARG_1_SIZE[DREM] = 2; ARG_1_SIZE[DDIV] = 2; ARG_1_SIZE[DMUL] = 2; ARG_1_SIZE[DSUB] = 2; ARG_1_SIZE[DADD] = 2; ARG_1_SIZE[LUSHR] = 2; ARG_1_SIZE[LSHR] = 2; ARG_1_SIZE[LSHL] = 2; ARG_1_SIZE[LREM] = 2; ARG_1_SIZE[LDIV] = 2; ARG_1_SIZE[LMUL] = 2; ARG_1_SIZE[LSUB] = 2; ARG_1_SIZE[LADD] = 2; ARG_1_SIZE[LAND] = 2; ARG_1_SIZE[LOR] = 2; ARG_1_SIZE[LXOR] = 2; ARG_1_SIZE[FCMPL] = 1; ARG_1_SIZE[FCMPG] = 1; ARG_1_SIZE[LCMP] = 2; ARG_1_SIZE[DCMPL] = 2; ARG_1_SIZE[DCMPG] = 2; System.arraycopy(ARG_1_SIZE, 0, ARG_2_SIZE, 0, ARG_1_SIZE.length); ARG_2_SIZE[LUSHR] = 1; ARG_2_SIZE[LSHR] = 1; ARG_2_SIZE[LSHL] = 1; // The rest of these aren't "operations" like the above ARG_1_SIZE[DUP] = 1; ARG_1_SIZE[DUP_X1] = 1; ARG_1_SIZE[DUP_X2] = 1; ARG_1_SIZE[DUP2] = 1; ARG_2_SIZE[DUP2] = 1; ARG_1_SIZE[DUP2_X1] = 1; ARG_2_SIZE[DUP2_X1] = 1; ARG_1_SIZE[DUP2_X2] = 1; ARG_2_SIZE[DUP2_X2] = 1; ARG_1_SIZE[POP] = 1; ARG_1_SIZE[POP2] = 1; ARG_2_SIZE[POP2] = 1; } /** * Wrapper of two {@link Argument}. * * @param argument2 * Argument providing the left side value of a binary operation. * @param argument1 * Argument providing the right side value of a binary operation. * @param combinedIntermediates * Track any intermediate instructions between the operation instruction and the argument's instructions. */ public record BinaryOperationArguments(@Nonnull Argument argument2, @Nonnull Argument argument1, @Nonnull List combinedIntermediates) { /** * Replace the instructions from the wrapped arguments with {@code nop} * or other value providing instructions if the stack state necessitates it. * * @param instructions * Instructions list to modify. */ public void replaceBinOp(@Nonnull InsnList instructions) { // Replace right binary operation value. argument2.replaceInsn(instructions); // Replace left binary operation value (If the right argument hasn't provided for both arguments). if (!argument1.sameAs(argument2)) argument1.replaceInsn(instructions); // Remove any intermediate instructions. for (AbstractInsnNode intermediate : combinedIntermediates) if (instructions.contains(intermediate)) instructions.set(intermediate, new InsnNode(NOP)); } } /** * @param insn * Instruction that may act as a provider for the operation instruction. * @param intermediates * Instructions between the operation instruction and the argument instruction. * @param intermediateStackConsumption * Track any intermediate instructions between the operation instruction and the argument instruction. */ public record Argument(@Nonnull AbstractInsnNode insn, @Nonnull List intermediates, int intermediateStackConsumption) { /** * @return {@code true} when {@link #intermediates()} is empty. */ public boolean hasIntermediates() { return !intermediates.isEmpty(); } /** * @param other * Some other argument. * * @return {@code true} when both this and the other arg wrap the same instruction. */ public boolean sameAs(@Nonnull Argument other) { return insn == other.insn; } /** * @param other * Some other argument. * * @return Combined stack consumption of this and the other argument. */ public int getCombinedStackConsumption(@Nonnull Argument other) { return sameAs(other) ? intermediateStackConsumption : intermediateStackConsumption + other.intermediateStackConsumption; } /** * @param other * Some other argument. * * @return Combined intermediates of both this and the other argument. */ @Nonnull public List getCombinedIntermediates(@Nonnull Argument other) { if (!hasIntermediates() && !other.hasIntermediates()) return Collections.emptyList(); if (sameAs(other) || !other.hasIntermediates()) return intermediates; return Lists.combine(intermediates, other.intermediates); } @Override public String toString() { String string = BlwUtil.toString(insn); if (intermediates.isEmpty()) return string; return string + "\n - " + intermediates.stream().map(BlwUtil::toString).collect(Collectors.joining("\n - ")); } /** * Check if this {@link #insn()} provides values for the given {@code opcode}'s operation. * * @param opcode * Some binary operation (Instruction operating on two stack values) * * @return {@code true} if this argument {@link #insn() instruction} supplies the binary operation with both stack values. * {@code false} if this argument provides only one or none of the values. */ public boolean providesBinaryOpValuesFor(int opcode) { // Get the required sizes of arguments for the given instruction. int arg1Size = ARG_1_SIZE[opcode]; int arg2Size = ARG_2_SIZE[opcode]; if (arg1Size < 0 || arg2Size < 0) throw new IllegalStateException("Missing arg sizes for op: " + opcode); // Cover cases like long/doubles int totalArgSize = arg1Size + arg2Size; if (getSizeProduced(insn) == totalArgSize) return true; // The ONLY case where this is valid is DUP2 for some non-wide op (like IADD). // Other stack modifying instructions like DUP_X1/X2 + DUP2_X1/X2 move the values below the stack. return insn.getOpcode() == DUP2 && arg1Size == 1 && arg2Size == 1; } /** * Replace the {@link #insn()} with either a {@code nop} or some other value providing instruction (Depending on calling circumstances). * * @param instructions * Instructions list to modify. * * @return {@code true} when the instructions list was successfully modified. * {@code false} when no replacement could be made. */ public boolean replaceInsn(@Nonnull InsnList instructions) { instructions.set(insn, new InsnNode(NOP)); return true; } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/OpaquePredicateFoldingTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import jakarta.inject.Inject; import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.InsnList; import org.objectweb.asm.tree.InsnNode; import org.objectweb.asm.tree.JumpInsnNode; import org.objectweb.asm.tree.LookupSwitchInsnNode; import org.objectweb.asm.tree.MethodNode; import org.objectweb.asm.tree.TableSwitchInsnNode; import org.objectweb.asm.tree.analysis.Frame; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.services.inheritance.InheritanceGraphService; import software.coley.recaf.services.transform.ClassTransformer; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.util.AsmInsnUtil; import software.coley.recaf.util.analysis.value.IntValue; import software.coley.recaf.util.analysis.value.ObjectValue; import software.coley.recaf.util.analysis.value.ReValue; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.Collections; import java.util.Set; import java.util.function.BiPredicate; import java.util.function.Predicate; import static org.objectweb.asm.Opcodes.*; import static software.coley.recaf.services.deobfuscation.transform.generic.OpaqueConstantFoldingTransformer.isSupportedValueProducer; import static software.coley.recaf.util.AsmInsnUtil.isFlowControl; import static software.coley.recaf.util.AsmInsnUtil.isSwitchEffectiveGoto; /** * A transformer that folds opaque predicates into single-path control flows. * * @author Matt Coley */ @Dependent public class OpaquePredicateFoldingTransformer implements JvmClassTransformer { private final InheritanceGraphService graphService; private InheritanceGraph inheritanceGraph; @Inject public OpaquePredicateFoldingTransformer(@Nonnull InheritanceGraphService graphService) { this.graphService = graphService; } @Override public void setup(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace) { inheritanceGraph = graphService.getOrCreateInheritanceGraph(workspace); } @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { boolean dirty = false; String className = initialClassState.getName(); ClassNode node = context.getNode(bundle, initialClassState); for (MethodNode method : node.methods) { InsnList instructions = method.instructions; // Skip if method is abstract. if (instructions == null) continue; // Some obfuscators will use 'switch' instructions with all labels being the same in order to // recreate the behavior of 'goto'. We will just replace these if we see them. // We do this in a pre-pass here since we end up inserting an additional 'POP' for any matched switch. for (int i = 1; i < instructions.size() - 1; i++) { AbstractInsnNode instruction = instructions.get(i); if (instruction instanceof TableSwitchInsnNode switchInsn && isSwitchEffectiveGoto(switchInsn)) { AbstractInsnNode previous = switchInsn.getPrevious(); if (isValueProducerOrTopDup(previous)) instructions.remove(previous); else instructions.insertBefore(switchInsn, new InsnNode(POP)); instructions.set(switchInsn, new JumpInsnNode(GOTO, switchInsn.dflt)); dirty = true; } else if (instruction instanceof LookupSwitchInsnNode switchInsn && isSwitchEffectiveGoto(switchInsn)) { AbstractInsnNode previous = switchInsn.getPrevious(); if (isValueProducerOrTopDup(previous)) instructions.remove(previous); else instructions.insertBefore(switchInsn, new InsnNode(POP)); instructions.set(switchInsn, new JumpInsnNode(GOTO, switchInsn.dflt)); dirty = true; } } try { boolean localDirty = false; Frame[] frames = context.analyze(inheritanceGraph, node, method); for (int i = 1; i < instructions.size() - 1; i++) { AbstractInsnNode instruction = instructions.get(i); // Skip if this isn't a control flow instruction. // We are only flattening control flow here. if (!isFlowControl(instruction)) continue; // Skip goto, branch is always taken. // Use the goto inliner if you want to clean these up. if (instruction.getOpcode() == GOTO) continue; // Skip if there is no frame for this instruction. if (i >= frames.length) continue; // Can happen if there is dead code at the end Frame frame = frames[i]; if (frame == null || frame.getStackSize() == 0) continue; // Skip if stack top is not known. ReValue stackTop = frame.getStack(frame.getStackSize() - 1); if (!stackTop.hasKnownValue() && !(stackTop instanceof ObjectValue ov && ov.isNull())) continue; // Get instruction of the top stack's contributing instruction. // It must also be a value producing instruction. // If this is something that isn't value producing, another transformer needs to simplify it first. AbstractInsnNode prevInstruction = AsmInsnUtil.getPreviousInsn(instruction); if (prevInstruction == null || !isValueProducerOrTopDup(prevInstruction)) continue; // Handle any control flow instruction and see if we know based on the frame contents if a specific // path is always taken. int insnType = instruction.getType(); if (insnType == AbstractInsnNode.JUMP_INSN) { JumpInsnNode jin = (JumpInsnNode) instruction; int opcode = instruction.getOpcode(); if ((opcode >= IFEQ && opcode <= IFLE) || opcode == IFNULL || opcode == IFNONNULL) { // Replace single argument binary control flow. localDirty |= switch (opcode) { case IFEQ -> replaceIntValue(instructions, prevInstruction, stackTop, jin, v -> v.isEqualTo(0)); case IFNE -> replaceIntValue(instructions, prevInstruction, stackTop, jin, v -> v.isNotEqualTo(0)); case IFLT -> replaceIntValue(instructions, prevInstruction, stackTop, jin, v -> v.isLessThan(0)); case IFGE -> replaceIntValue(instructions, prevInstruction, stackTop, jin, v -> v.isGreaterThanOrEqual(0)); case IFGT -> replaceIntValue(instructions, prevInstruction, stackTop, jin, v -> v.isGreaterThan(0)); case IFLE -> replaceIntValue(instructions, prevInstruction, stackTop, jin, v -> v.isLessThanOrEqual(0)); case IFNULL -> replaceObjValue(instructions, prevInstruction, stackTop, jin, ObjectValue::isNull); case IFNONNULL -> replaceObjValue(instructions, prevInstruction, stackTop, jin, ObjectValue::isNotNull); default -> localDirty; }; } else if (opcode >= IF_ICMPEQ && opcode <= IF_ACMPNE) { // Skip if the other argument to compare with is not available or known. if (frame.getStackSize() < 2) continue; ReValue stack2ndTop = frame.getStack(frame.getStackSize() - 2); if (!stack2ndTop.hasKnownValue() && !(stack2ndTop instanceof ObjectValue ov && ov.isNull())) continue; // Skip if the other argument to compare with is not immediately backed by // a value supplying instruction. AbstractInsnNode prevPrevInstruction = prevInstruction.getPrevious(); if (prevPrevInstruction == null || !isValueProducerOrTopDup(prevPrevInstruction)) continue; // Replace double argument binary control flow. localDirty |= switch (opcode) { case IF_ICMPEQ -> replaceIntIntValue(instructions, prevPrevInstruction, prevInstruction, stack2ndTop, stackTop, jin, IntValue::isEqualTo); case IF_ICMPNE -> replaceIntIntValue(instructions, prevPrevInstruction, prevInstruction, stack2ndTop, stackTop, jin, IntValue::isNotEqualTo); case IF_ICMPLT -> replaceIntIntValue(instructions, prevPrevInstruction, prevInstruction, stack2ndTop, stackTop, jin, IntValue::isLessThan); case IF_ICMPGE -> replaceIntIntValue(instructions, prevPrevInstruction, prevInstruction, stack2ndTop, stackTop, jin, IntValue::isGreaterThanOrEqual); case IF_ICMPGT -> replaceIntIntValue(instructions, prevPrevInstruction, prevInstruction, stack2ndTop, stackTop, jin, IntValue::isGreaterThan); case IF_ICMPLE -> replaceIntIntValue(instructions, prevPrevInstruction, prevInstruction, stack2ndTop, stackTop, jin, IntValue::isLessThanOrEqual); case IF_ACMPEQ -> replaceObjObjValue(instructions, prevPrevInstruction, prevInstruction, stack2ndTop, stackTop, jin, (a, b) -> a.isNull() && b.isNull(), // Both null --> both are equal (a, b) -> (a.isNull() && b.isNotNull()) || (a.isNotNull() && b.isNull())); // Nullability conflict, both cannot be equal case IF_ACMPNE -> replaceObjObjValue(instructions, prevPrevInstruction, prevInstruction, stack2ndTop, stackTop, jin, (a, b) -> (a.isNull() && b.isNotNull()) || (a.isNotNull() && b.isNull()), // Nullability conflict, both cannot be equal (a, b) -> a.isNull() && b.isNull()); // Both null --> both are equal default -> localDirty; }; } } else if (insnType == AbstractInsnNode.LOOKUPSWITCH_INSN) { LookupSwitchInsnNode lsin = (LookupSwitchInsnNode) instruction; // Skip if stack top is not an integer. if (!(stackTop instanceof IntValue intValue)) continue; // Find matching key in switch. int keyIndex = -1; for (int j = 0; j < lsin.keys.size(); j++) { int key = lsin.keys.get(j); if (intValue.isEqualTo(key)) { keyIndex = j; break; } } // Replace switch with goto for the appropriate control flow path. JumpInsnNode replacement = keyIndex == -1 ? new JumpInsnNode(GOTO, lsin.dflt) : new JumpInsnNode(GOTO, lsin.labels.get(keyIndex)); instructions.set(lsin, replacement); instructions.set(prevInstruction, new InsnNode(NOP)); localDirty = true; } else if (insnType == AbstractInsnNode.TABLESWITCH_INSN) { TableSwitchInsnNode tsin = (TableSwitchInsnNode) instruction; // Skip if stack top is not an integer. if (!(stackTop instanceof IntValue intValue)) continue; // Find matching key in switch. int arg = intValue.value().getAsInt(); int keyIndex = (arg > tsin.max || arg < tsin.min) ? -1 : (arg - tsin.min); // Replace switch with goto for the appropriate control flow path. JumpInsnNode replacement = keyIndex == -1 ? new JumpInsnNode(GOTO, tsin.dflt) : new JumpInsnNode(GOTO, tsin.labels.get(keyIndex)); instructions.set(tsin, replacement); instructions.set(prevInstruction, new InsnNode(NOP)); localDirty = true; } } // Clear any code that is no longer accessible. If we don't do this step ASM's auto-cleanup // will likely leave some ugly artifacts like "athrow" in dead code regions. if (localDirty) { context.pruneDeadCode(node, method); dirty = true; } } catch (Throwable t) { throw new TransformationException("Error encountered when folding opaque predicates", t); } } if (dirty) { context.setRecomputeFrames(initialClassState.getName()); context.setNode(bundle, initialClassState, node); } } private static boolean replaceIntValue(@Nonnull InsnList instructions, @Nonnull AbstractInsnNode stackValueProducerInsn, @Nonnull ReValue stackTopValue, @Nonnull JumpInsnNode jump, @Nonnull Predicate gotoCondition) { if (stackTopValue instanceof IntValue intValue) { AbstractInsnNode replacement = gotoCondition.test(intValue) ? new JumpInsnNode(GOTO, jump.label) : new InsnNode(NOP); instructions.set(jump, replacement); instructions.set(stackValueProducerInsn, new InsnNode(NOP)); return true; } return false; } private static boolean replaceIntIntValue(@Nonnull InsnList instructions, @Nonnull AbstractInsnNode stackValueProducerInsnA, @Nonnull AbstractInsnNode stackValueProducerInsnB, @Nonnull ReValue stackTopValueA, @Nonnull ReValue stackTopValueB, @Nonnull JumpInsnNode jump, @Nonnull BiPredicate gotoCondition) { if (stackTopValueA instanceof IntValue intValueA && stackTopValueB instanceof IntValue intValueB) { AbstractInsnNode replacement = gotoCondition.test(intValueA, intValueB) ? new JumpInsnNode(GOTO, jump.label) : new InsnNode(NOP); instructions.set(jump, replacement); instructions.set(stackValueProducerInsnA, new InsnNode(NOP)); instructions.set(stackValueProducerInsnB, new InsnNode(NOP)); return true; } return false; } private static boolean replaceObjValue(@Nonnull InsnList instructions, @Nonnull AbstractInsnNode stackValueProducerInsn, @Nonnull ReValue stackTopValue, @Nonnull JumpInsnNode jump, @Nonnull Predicate gotoCondition) { if (stackTopValue instanceof ObjectValue objectValue) { AbstractInsnNode replacement = gotoCondition.test(objectValue) ? new JumpInsnNode(GOTO, jump.label) : new InsnNode(NOP); instructions.set(jump, replacement); instructions.set(stackValueProducerInsn, new InsnNode(NOP)); return true; } return false; } private static boolean replaceObjObjValue(@Nonnull InsnList instructions, @Nonnull AbstractInsnNode stackValueProducerInsnA, @Nonnull AbstractInsnNode stackValueProducerInsnB, @Nonnull ReValue stackTopValueA, @Nonnull ReValue stackTopValueB, @Nonnull JumpInsnNode jump, @Nonnull BiPredicate gotoCondition, @Nonnull BiPredicate fallCondition) { if (stackTopValueA instanceof ObjectValue objValueA && stackTopValueB instanceof ObjectValue objValueB) { // Objects are a bit more complicated than primitives, so we have separate checks for replacing as a goto // versus a fallthrough case. Additionally, if neither conditions pass we must be in a state where the values // are technically known, but not well enough to the point where we can make a decision. AbstractInsnNode replacement = gotoCondition.test(objValueA, objValueB) ? new JumpInsnNode(GOTO, jump.label) : null; if (replacement == null) replacement = fallCondition.test(objValueA, objValueB) ? new InsnNode(NOP) : null; if (replacement == null) return false; instructions.set(jump, replacement); instructions.set(stackValueProducerInsnA, new InsnNode(NOP)); instructions.set(stackValueProducerInsnB, new InsnNode(NOP)); return true; } return false; } private static boolean isValueProducerOrTopDup(@Nonnull AbstractInsnNode insnNode) { if (isSupportedValueProducer(insnNode)) return true; int op = insnNode.getOpcode(); return op == DUP || op == DUP2; } @Nonnull @Override public Set> recommendedPredecessors() { return Set.of( // Folding opaque constants like "1 + 1" into "2" OpaqueConstantFoldingTransformer.class, // Folding static constants into inline-usages (some obfuscators use fields to store opaque flow values) StaticValueInliningTransformer.class, // Folding constant values stored in variables VariableFoldingTransformer.class ); } @Nonnull @Override public Set> dependencies() { return Collections.singleton(DeadCodeRemovingTransformer.class); } @Nonnull @Override public String name() { return "Opaque predicate simplification"; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/RedundantTryCatchRemovingTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import it.unimi.dsi.fastutil.ints.Int2ObjectMap; import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.Dependent; import jakarta.inject.Inject; import org.objectweb.asm.Type; import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.InsnList; import org.objectweb.asm.tree.LabelNode; import org.objectweb.asm.tree.MethodInsnNode; import org.objectweb.asm.tree.MethodNode; import org.objectweb.asm.tree.MultiANewArrayInsnNode; import org.objectweb.asm.tree.TryCatchBlockNode; import org.objectweb.asm.tree.TypeInsnNode; import org.objectweb.asm.tree.analysis.Frame; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.services.inheritance.InheritanceGraphService; import software.coley.recaf.services.inheritance.InheritanceVertex; import software.coley.recaf.services.transform.ClassTransformer; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.util.AsmInsnUtil; import software.coley.recaf.util.Types; import software.coley.recaf.util.analysis.value.ArrayValue; import software.coley.recaf.util.analysis.value.IntValue; import software.coley.recaf.util.analysis.value.LongValue; import software.coley.recaf.util.analysis.value.ObjectValue; import software.coley.recaf.util.analysis.value.ReValue; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Deque; import java.util.HashMap; import java.util.HashSet; import java.util.IdentityHashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.OptionalInt; import java.util.Set; import java.util.stream.Collectors; import static org.objectweb.asm.Opcodes.*; /** * A transformer that removes redundant try-catch blocks. * * @author Matt Coley */ @Dependent public class RedundantTryCatchRemovingTransformer implements JvmClassTransformer { private static final String EX_NPE = "java/lang/NullPointerException"; private static final String EX_ASE = "java/lang/ArrayStoreException"; private static final String EX_AIOOBE = "java/lang/ArrayIndexOutOfBoundsException"; private static final String EX_NASE = "java/lang/NegativeArraySizeException"; private static final String EX_IMSE = "java/lang/IllegalMonitorStateException"; private static final String EX_CCE = "java/lang/ClassCastException"; private static final String EX_AE = "java/lang/ArithmeticException"; private final InheritanceGraphService graphService; private InheritanceGraph inheritanceGraph; private ExceptionCollectionTransformer exceptionCollector; @Inject public RedundantTryCatchRemovingTransformer(@Nonnull InheritanceGraphService graphService) { this.graphService = graphService; } @Override public void setup(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace) { inheritanceGraph = graphService.getOrCreateInheritanceGraph(workspace); } @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { boolean dirty = false; ClassNode node = context.getNode(bundle, initialClassState); exceptionCollector = context.getJvmTransformer(ExceptionCollectionTransformer.class); for (MethodNode method : node.methods) { // Skip methods that have no code or no try-catch blocks, as they can't have redundant entries. if (method.instructions == null || method.instructions.size() == 0) continue; if (method.tryCatchBlocks == null || method.tryCatchBlocks.isEmpty()) continue; try { dirty |= pruneRedundantTryCatches(context, node, method); } catch (TransformationException ex) { throw ex; } catch (Throwable t) { throw new TransformationException("Error encountered when removing redundant try-catch blocks", t); } } // If we changed anything, we need to update the class node and mark frames for recomputation. if (dirty) { context.setRecomputeFrames(initialClassState.getName()); context.setNode(bundle, initialClassState, node); } } @Nonnull @Override public Set> dependencies() { return Set.of(ExceptionCollectionTransformer.class, DeadCodeRemovingTransformer.class); } @Nonnull @Override public String name() { return "Redundant try-catch removal"; } /** * Removes redundant try-catch entries from the given method. * * @param context * Transformation context. * @param declaringClass * Class declaring the method. * @param method * Method to transform. * * @return {@code true} when the method was changed. * * @throws TransformationException * When dead-code pruning fails. */ private boolean pruneRedundantTryCatches(@Nonnull JvmTransformerContext context, @Nonnull ClassNode declaringClass, @Nonnull MethodNode method) throws TransformationException { InsnList instructions = method.instructions; // Snapshot the original state of the try-catch blocks so we can check if we made any changes at the end. List originalState = snapshotStates(instructions, method.tryCatchBlocks); // Pruning occurs in multiple passes to allow later passes to take advantage of the results of earlier ones. List tryCatches = mergeContinuousRanges(instructions, method.tryCatchBlocks); tryCatches = removeExactDuplicates(instructions, tryCatches); tryCatches = removeShadowedRanges(instructions, tryCatches); // Last pass requires frame analysis, so we do it after the cheaper passes to minimize the number of frames we need to analyze. Frame[] frames = context.analyze(inheritanceGraph, declaringClass, method); tryCatches = removeImpossibleCatches(instructions, frames, tryCatches); // If the final state is the same as the original state, we don't need to update anything. List updatedState = snapshotStates(instructions, tryCatches); if (originalState.equals(updatedState)) return false; // Update the method's try-catch blocks and prune any now-unreachable code. method.tryCatchBlocks.clear(); method.tryCatchBlocks.addAll(tryCatches); context.pruneDeadCode(declaringClass, method); return true; } /** * Removes ranges that cannot possibly be utilized at runtime. * * @param instructions * Method instructions. * @param tryCatches * Method try-catches. * * @return Deduplicated try-catch list. */ @Nonnull private List removeIgnoredRanges(@Nonnull InsnList instructions, @Nonnull List tryCatches) { // Collect all try-catch handlers keyed by their range Map> handlersMap = new HashMap<>(); for (TryCatchBlockNode tryCatch : tryCatches) { int start = codeBoundaryIndex(instructions, tryCatch.start); int end = codeBoundaryIndex(instructions, tryCatch.end); if (start < end) { TryRange range = new TryRange(start, end); handlersMap.computeIfAbsent(range, r -> new ArrayList<>()).add(tryCatch); } } // Prune handlers of narrower (or equal) types in the collection // - Gives preference to handlers that appear first in the list, since the JVM will check them first. new HashMap<>(handlersMap).forEach((range, blocks) -> { Set seenTypes = new HashSet<>(); Iterator it = blocks.iterator(); while (it.hasNext()) { TryCatchBlockNode block = it.next(); String handledType = Objects.requireNonNullElse(block.type, "java/lang/Object"); inner: { for (String seenType : seenTypes) { if (inheritanceGraph.isAssignableFrom(seenType, handledType)) { it.remove(); break inner; } } seenTypes.add(handledType); } } }); // Retain only remaining handlers in the collection Set allHandlers = handlersMap.values() .stream() .flatMap(Collection::stream) .collect(Collectors.toCollection(() -> Collections.newSetFromMap(new IdentityHashMap<>()))); List retained = new ArrayList<>(tryCatches); retained.retainAll(tryCatches.stream() .filter(allHandlers::contains) .toList()); return retained; } /** * Merges adjacent ranges with identical handler targets and catch types. * Take this example scenario where we have multiple try-catch blocks that all * catch the same exception type and point to the same handler: *
{@code
	 *      try-handler: range=[A-B] handler=D:*
	 *      try-handler: range=[B-C] handler=D:*
	 *      try-handler: range=[C-D] handler=D:*
	 *      --- D handler ----
	 *      try-handler: range=[E-F] handler=D:*
	 *      try-handler: range=[F-G] handler=D:*
	 *      try-handler: range=[G-H] handler=D:*
	 * }
* This can be simplified to: *
{@code
	 *      try-handler: range=[A-D] handler=D:*
	 *      --- D handler ----
	 *      try-handler: range=[E-H] handler=D:*
	 * }
* * @param instructions * Method instructions. * @param tryCatches * Method try-catches. * * @return Condensed try-catch list. */ @Nonnull private static List mergeContinuousRanges(@Nonnull InsnList instructions, @Nonnull List tryCatches) { // Skip if there is only one entry, as it can't be merged with anything else. if (tryCatches.size() <= 1) return new ArrayList<>(tryCatches); // Compare each entry with the previous one to see if the ranges are adjacent and have the same handler and catch type. // We can keep merging as long as the entries are continuous, so we update the "previous" entry's end to merge the ranges together. // This results in intermediate try-catch entries being removed from the yielded list. List merged = new ArrayList<>(tryCatches.size()); TryCatchBlockNode previous = null; for (TryCatchBlockNode current : tryCatches) { if (previous != null && Objects.equals(previous.type, current.type) && codeBoundaryIndex(instructions, previous.end) == codeBoundaryIndex(instructions, current.start) && codeBoundaryIndex(instructions, previous.handler) == codeBoundaryIndex(instructions, current.handler)) { previous.end = current.end; continue; } merged.add(current); previous = current; } return merged; } /** * Removes exact duplicate entries while preserving the first occurrence. * * @param instructions * Method instructions. * @param tryCatches * Method try-catches. * * @return Deduplicated try-catch list. */ @Nonnull private static List removeExactDuplicates(@Nonnull InsnList instructions, @Nonnull List tryCatches) { // Skip if there is only one entry, as it can't be merged with anything else. if (tryCatches.size() <= 1) return new ArrayList<>(tryCatches); // Simple merge pass that uses a set to track seen entries. // We can use the snapshot state as a unique identifier for try-catch blocks, since it captures all relevant properties of the block. Set seen = new HashSet<>(tryCatches.size()); List kept = new ArrayList<>(tryCatches.size()); for (TryCatchBlockNode tryCatch : tryCatches) if (seen.add(snapshotState(instructions, tryCatch))) kept.add(tryCatch); return kept; } /** * Removes entries that are fully shadowed by an earlier, broader entry in the exception table. * Given the following {@code { start, end, handler, ex-type } } blocks: *
{@code
	 * { R, S, Q, * },
	 * { R, S, C, * },
	 * { R, S, S, Ljava/lang/ArrayIndexOutOfBoundsException; }
	 * }
* Only the first is going to be used. *
    *
  • It appears first, so it will be checked first by the JVM
  • *
  • Its range covers all possible instructions of the other two try blocks
  • *
  • Its handled type is more generic ({@code "*"} is catch-all/null)
  • *
* See: method.cpp#fast_exception_handler_bci_for * * @param instructions * Method instructions. * @param tryCatches * Method try-catches. * * @return Try-catch list without shadowed entries. */ @Nonnull private List removeShadowedRanges(@Nonnull InsnList instructions, @Nonnull List tryCatches) { List kept = new ArrayList<>(tryCatches.size()); for (TryCatchBlockNode tryCatch : tryCatches) { TryCatchState state = snapshotState(instructions, tryCatch); // Compare this try-catch block against all previously-kept blocks to see if // it is fully covered by any of them and has a more specific catch type. boolean shadowed = false; for (TryCatchBlockNode previous : kept) { TryCatchState previousState = snapshotState(instructions, previous); if (previousState.covers(state) && catchesSameOrBroaderException(previous.type, tryCatch.type)) { shadowed = true; break; } } // If the block is not shadowed by any previous block, we keep it for the final list. if (!shadowed) kept.add(tryCatch); } return kept; } /** * Removes entries that cannot be matched by any reachable instruction in their protected range. * * @param instructions * Method instructions. * @param frames * Method stack frames. * @param tryCatches * Method try-catches. * * @return Filtered try-catch list. */ @Nonnull private List removeImpossibleCatches(@Nonnull InsnList instructions, @Nonnull Frame[] frames, @Nonnull List tryCatches) { List kept = new ArrayList<>(tryCatches.size()); for (TryCatchBlockNode tryCatch : tryCatches) if (canCatchBeUsed(instructions, frames, tryCatch)) kept.add(tryCatch); return kept; } /** * @param instructions * Method instructions. * @param frames * Method stack frames. * @param tryCatch * Try-catch entry to inspect. * * @return {@code true} when the try-catch has a reachable protected instruction that may match it. */ private boolean canCatchBeUsed(@Nonnull InsnList instructions, @Nonnull Frame[] frames, @Nonnull TryCatchBlockNode tryCatch) { // If the catch type is a type defined in the workspace, but never thrown in the workspace, // then it can't be caught at runtime, and we can remove the try-catch block. String catchType = tryCatch.type; if (catchType != null && isWorkspaceExceptionNeverThrown(catchType)) return false; int start = codeBoundaryIndex(instructions, tryCatch.start); int end = codeBoundaryIndex(instructions, tryCatch.end); int handler = codeBoundaryIndex(instructions, tryCatch.handler); // If the start and end are the same, or the start is beyond the end, // then there are no instructions protected by this try-catch, so it can't be used. if (start >= end) return false; // Determine which instructions in the protected range are reachable by normal control-flow (ignoring exception edges). boolean[] visited = computeVisitedInstructions(instructions, frames, start, end); // Check each instruction in the protected range to see if any of them can // throw an exception that would be caught by this try-catch block. for (int i = start; i < end; i++) { // not reachable by normal flow within the protected range if (!visited[i]) continue; // If there is no frame for this instruction, it means the instruction is unreachable, so we can skip it. Frame frame = i < frames.length ? frames[i] : null; if (frame == null) continue; // If the catch type is null, we check for any exception throwing potential. // Otherwise, we only check for the handler's caught type. AbstractInsnNode insn = instructions.get(i); if (catchType == null) { if (canInsnThrowAnyException(insn, frame)) return true; } else if (canInsnThrowCaughtException(insn, frame, catchType)) { return true; } } return false; } /** * Compute which instructions in the protected range of a try-catch block * are reachable by normal control-flow (ignoring exception edges). *

* This is necessary for some edge cases where the protected range may * include instructions that are not actually reachable without an exception being thrown. * For example, consider the following code snippet: *

{@code
	 * .method public static example ()V {
	 *     exceptions: {
	 *         { A, C, B, Ljava/lang/RuntimeException; }
	 *      },
	 *     code: {
	 *     A:
	 *         // try-start - protected by B and nothing in here can throw RuntimeException
	 *         goto C
	 *     B:
	 *         // try-handler - but also inside the range A-C
	 *         //               we should not consider any instruction here as reachable by normal flow
	 *         dup
	 *         invokevirtual java/lang/RuntimeException.printStackTrace ()V
	 *         checkcast java/lang/Throwable
	 *         athrow
	 *     C:
	 *         // try-end
	 *         goto END
	 *     END:
	 *         return
	 *     }
	 * }
	 * }
* * @param instructions * Method instructions. * @param frames * Method stack frames. * @param start * Protected range start. * @param end * Protected range end. * * @return Boolean array of the same length as instructions, * where each index is the visited state within the protected range. */ private static boolean[] computeVisitedInstructions(@Nonnull InsnList instructions, @Nonnull Frame[] frames, int start, int end) { // Build normal control-flow adjacency using the shared helper (no exception edges). Int2ObjectMap> successorMap = new Int2ObjectOpenHashMap<>(); Int2ObjectMap> predecessorMap = new Int2ObjectOpenHashMap<>(); // Wrap instructions into a temporary MethodNode so populateFlowMaps can operate. MethodNode temp = new MethodNode(); temp.instructions = instructions; temp.tryCatchBlocks = Collections.emptyList(); // Populate flow maps without exception edges. AsmInsnUtil.populateFlowMaps(temp, successorMap, predecessorMap, false); // Determine entry nodes into the [start, end) range: // any node in range that has a predecessor outside the range, or the range start itself. int size = instructions.size(); Deque queue = new ArrayDeque<>(); boolean[] visited = new boolean[size]; for (int i = start; i < end && i < size; i++) { boolean hasOutsidePredecessor = false; for (int predecessor : predecessorMap.getOrDefault(i, Collections.emptyList())) { if (predecessor < start || predecessor >= end) { hasOutsidePredecessor = true; break; } } if (hasOutsidePredecessor || i == start) { queue.add(i); visited[i] = true; } } // If we found no entry but the start instruction is reachable according to frames, include it. if (queue.isEmpty() && start < frames.length && frames[start] != null) { queue.add(start); visited[start] = true; } // BFS within the protected range following normal control-flow only. while (!queue.isEmpty()) { int cur = queue.removeFirst(); for (int to : successorMap.getOrDefault(cur, Collections.emptyList())) { if (to >= start && to < end && !visited[to]) { visited[to] = true; queue.addLast(to); } } } return visited; } /** * @param tryCatchType * Catch type from the exception table. * * @return {@code true} when the catch type belongs to the primary resource and is never thrown there. */ private boolean isWorkspaceExceptionNeverThrown(@Nonnull String tryCatchType) { InheritanceVertex vertex = inheritanceGraph.getVertex(tryCatchType); if (vertex == null || vertex.isLibraryVertex() || vertex.isModule()) return false; return !exceptionCollector.getThrownExceptions().contains(tryCatchType); } /** * @param insn * Instruction to inspect. * @param frame * Stack frame before the instruction. * * @return {@code true} when the instruction may throw any exception relevant to this transformer. */ private boolean canInsnThrowAnyException(@Nonnull AbstractInsnNode insn, @Nonnull Frame frame) { // Most method calls can throw exceptions. // Since we are looking for *any* potential exception, we can just assume all method calls can throw. if (insn instanceof MethodInsnNode) return true; return switch (insn.getOpcode()) { case ATHROW, FDIV, FREM, DDIV, DREM -> true; case ARRAYLENGTH, MONITORENTER, GETFIELD -> isReferencePossiblyNull(peekStack(frame, 0)); case PUTFIELD -> isReferencePossiblyNull(peekStack(frame, 1)); case IALOAD, LALOAD, FALOAD, DALOAD, AALOAD, BALOAD, CALOAD, SALOAD -> canArrayAccessThrow(peekStack(frame, 1), peekStack(frame, 0)); case IASTORE, LASTORE, FASTORE, DASTORE, AASTORE, BASTORE, CASTORE, SASTORE -> canArrayStoreThrow(insn.getOpcode(), peekStack(frame, 2), peekStack(frame, 1), peekStack(frame, 0)); case IDIV, IREM, LDIV, LREM -> isZeroDivisorPossible(peekStack(frame, 0)); case NEWARRAY, ANEWARRAY -> isNegativeSizePossible(peekStack(frame, 0)); case MULTIANEWARRAY -> isNegativeMultiArraySizePossible((MultiANewArrayInsnNode) insn, frame); case CHECKCAST -> canCheckCastThrow((TypeInsnNode) insn, peekStack(frame, 0)); case MONITOREXIT -> isReferencePossiblyNull(peekStack(frame, 0)) || !isReferenceKnownNull(peekStack(frame, 0)); default -> false; }; } /** * @param insn * Instruction to inspect. * @param frame * Stack frame before the instruction. * @param catchType * Caught exception type. * * @return {@code true} when the instruction may throw an exception assignable to the catch type. */ private boolean canInsnThrowCaughtException(@Nonnull AbstractInsnNode insn, @Nonnull Frame frame, @Nonnull String catchType) { // While we may be able to generalize that some methods are unlikely to throw certain exceptions, // it's safer to assume that all method calls can throw something that would be caught by the catch block. // - If we wanted to add a heuristic here, we would check if the method's declaring class is a library class // and if the exception type is defined in the workspace as a checked exception. // - Library methods are unlikely to throw user-defined exceptions, especially checked ones. if (insn instanceof MethodInsnNode min) return true; return switch (insn.getOpcode()) { case ATHROW -> canAthrowThrow(catchType, peekStack(frame, 0)); case GETFIELD, ARRAYLENGTH, MONITORENTER -> canThrowNullPointer(catchType, peekStack(frame, 0)); case PUTFIELD -> canThrowNullPointer(catchType, peekStack(frame, 1)); case IALOAD, LALOAD, FALOAD, DALOAD, AALOAD, BALOAD, CALOAD, SALOAD -> canArrayAccessThrow(catchType, peekStack(frame, 1), peekStack(frame, 0)); case IASTORE, LASTORE, FASTORE, DASTORE, AASTORE, BASTORE, CASTORE, SASTORE -> canArrayStoreThrow(catchType, insn.getOpcode(), peekStack(frame, 2), peekStack(frame, 1), peekStack(frame, 0)); case IDIV, IREM, LDIV, LREM, FDIV, FREM, DDIV, DREM -> canArithmeticThrow(catchType, insn.getOpcode(), peekStack(frame, 0)); case NEWARRAY, ANEWARRAY -> isCaughtException(catchType, EX_NASE) && isNegativeSizePossible(peekStack(frame, 0)); case MULTIANEWARRAY -> isCaughtException(catchType, EX_NASE) && isNegativeMultiArraySizePossible((MultiANewArrayInsnNode) insn, frame); case CHECKCAST -> isCaughtException(catchType, EX_CCE) && canCheckCastThrow((TypeInsnNode) insn, peekStack(frame, 0)); case MONITOREXIT -> canMonitorExitThrow(catchType, peekStack(frame, 0)); default -> false; }; } /** * @param arrayValue * Array reference. * @param indexValue * Array index. * * @return {@code true} when an array read may throw NPE or AIOOBE. */ private static boolean canArrayAccessThrow(@Nullable ReValue arrayValue, @Nullable ReValue indexValue) { return isReferencePossiblyNull(arrayValue) || canArrayIndexThrow(arrayValue, indexValue); } /** * @param catchType * Caught exception type. * @param arrayValue * Array reference. * @param indexValue * Array index. * * @return {@code true} when an array read may throw an exception matched by the catch. */ private boolean canArrayAccessThrow(@Nonnull String catchType, @Nullable ReValue arrayValue, @Nullable ReValue indexValue) { return canThrowNullPointer(catchType, arrayValue) || (isCaughtException(catchType, EX_AIOOBE) && canArrayIndexThrow(arrayValue, indexValue)); } /** * @param opcode * Array-store opcode. * @param arrayValue * Array reference. * @param indexValue * Array index. * @param storedValue * Value being stored. * * @return {@code true} when an array store may throw any relevant exception. */ private boolean canArrayStoreThrow(int opcode, @Nullable ReValue arrayValue, @Nullable ReValue indexValue, @Nullable ReValue storedValue) { if (isReferencePossiblyNull(arrayValue) || canArrayIndexThrow(arrayValue, indexValue)) return true; return opcode == AASTORE && canArrayStoreTypeThrow(arrayValue, storedValue); } /** * @param catchType * Caught exception type. * @param opcode * Array-store opcode. * @param arrayValue * Array reference. * @param indexValue * Array index. * @param storedValue * Value being stored. * * @return {@code true} when an array store may throw an exception matched by the catch. */ private boolean canArrayStoreThrow(@Nonnull String catchType, int opcode, @Nullable ReValue arrayValue, @Nullable ReValue indexValue, @Nullable ReValue storedValue) { if (canThrowNullPointer(catchType, arrayValue)) return true; if (isCaughtException(catchType, EX_AIOOBE) && canArrayIndexThrow(arrayValue, indexValue)) return true; return opcode == AASTORE && isCaughtException(catchType, EX_ASE) && canArrayStoreTypeThrow(arrayValue, storedValue); } /** * @param catchType * Caught exception type. * @param opcode * Arithmetic opcode. * @param divisor * Divisor or remainder operand. * * @return {@code true} when the arithmetic instruction may throw an exception matched by the catch. */ private boolean canArithmeticThrow(@Nonnull String catchType, int opcode, @Nullable ReValue divisor) { // Skip if the catch doesn't even catch ArithmeticException. if (!isCaughtException(catchType, EX_AE)) return false; // For floating point division and remainder, the JVM does not throw an exception on division by zero. // - 1F / 0F -> Infinity // - 1F % 0F -> NaN if (opcode == FDIV || opcode == FREM || opcode == DDIV || opcode == DREM) return false; // For integer division and remainder, the JVM throws ArithmeticException on division by zero. return isZeroDivisorPossible(divisor); } /** * @param value * Divisor value. * * @return {@code true} when the divisor is unknown or zero. */ private static boolean isZeroDivisorPossible(@Nullable ReValue value) { if (value instanceof IntValue intValue) return intValue.value().isEmpty() || intValue.value().getAsInt() == 0; if (value instanceof LongValue longValue) return longValue.value().isEmpty() || longValue.value().getAsLong() == 0L; return true; } /** * @param catchType * Caught exception type. * @param objectValue * Thrown object value. * * @return {@code true} when {@code athrow} may be matched by the catch. */ private boolean canAthrowThrow(@Nonnull String catchType, @Nullable ReValue objectValue) { if (objectValue instanceof ObjectValue object) { // 'athrow' with a null reference will throw NPE, so we need to check for that possibility first. if (object.isNull()) return isCaughtException(catchType, EX_NPE); // If the reference is not null, but we don't know its type, we have to assume it could be anything, // including a type that would be caught by the catch block. if (!object.isNotNull() && isCaughtException(catchType, EX_NPE)) return true; // If we know the reference is not null, and we know its type, // we can check if that type could be caught by the catch block. Type valueType = object.type(); if (valueType.getSort() != Type.OBJECT) return true; return canReferenceRuntimeTypeMatch(valueType, catchType); } // If we don't know anything about the reference, lets just assume it can throw. return true; } /** * @param catchType * Caught exception type. * @param monitorValue * Monitor reference. * * @return {@code true} when {@code monitorexit} may throw an exception matched by the catch. */ private boolean canMonitorExitThrow(@Nonnull String catchType, @Nullable ReValue monitorValue) { return canThrowNullPointer(catchType, monitorValue) || (isCaughtException(catchType, EX_IMSE) && !isReferenceKnownNull(monitorValue)); } /** * @param cast * Cast instruction. * @param value * Value being cast. * * @return {@code true} when the cast may throw {@link ClassCastException}. */ private boolean canCheckCastThrow(@Nonnull TypeInsnNode cast, @Nullable ReValue value) { // Null can be cast to any type. if (isReferenceKnownNull(value)) return false; // If we don't know what type the value is, we can't safely assume the cast will succeed. Type sourceType = value == null ? null : value.type(); if (sourceType == null) return true; // Sanity check to ensure the value is actually a reference type, since only reference types can be cast. int sourceSort = sourceType.getSort(); if (sourceSort != Type.OBJECT && sourceSort != Type.ARRAY) return false; // Finally check if the target type is assignable from the source type. Type targetType = Type.getObjectType(cast.desc); return !isAssignable(targetType, sourceType); } /** * @param arrayValue * Array reference. * @param storedValue * Value being stored. * * @return {@code true} when {@code aastore} may throw {@link ArrayStoreException}. */ private boolean canArrayStoreTypeThrow(@Nullable ReValue arrayValue, @Nullable ReValue storedValue) { if (isReferenceKnownNull(arrayValue)) return false; if (storedValue instanceof ObjectValue object && object.isNull()) return false; if (!(arrayValue instanceof ArrayValue array)) return true; Type arrayType = array.type(); if (arrayType.getSort() != Type.ARRAY) return true; Type componentType = Types.undimension(arrayType); Type valueType = storedValue == null ? null : storedValue.type(); if (valueType == null) return true; return !isAssignable(componentType, valueType); } /** * @param targetType * Target type. * @param valueType * Value type. * * @return {@code true} when the value type is assignable to the target type. */ private boolean isAssignable(@Nonnull Type targetType, @Nonnull Type valueType) { // Base case, same type. if (targetType.equals(valueType)) return true; // Arrays can only be assigned to Object, and Object can be assigned from any array. int targetSort = targetType.getSort(); int valueSort = valueType.getSort(); if (targetSort == Type.ARRAY || valueSort == Type.ARRAY) return targetSort == Type.OBJECT && Types.OBJECT_TYPE.equals(targetType); // For non-object types, these are not assignable between one another. // This method is used strictly for checking casts and object type operations. // // If either type is not an object, then the cast is only valid if both types are the same primitive type, // which is already handled by the equality check above. if (targetSort != Type.OBJECT || valueSort != Type.OBJECT) return false; // Check inheritance graph for assignability of reference types. return inheritanceGraph.isAssignableFrom(targetType.getInternalName(), valueType.getInternalName()); } /** * @param value * Array size value. * * @return {@code true} when the size is unknown or negative. */ private static boolean isNegativeSizePossible(@Nullable ReValue value) { if (value instanceof IntValue intValue && intValue.value().isPresent()) return intValue.value().getAsInt() < 0; return true; } /** * @param multiArray * Multi-array instruction. * @param frame * Stack frame before the instruction. * * @return {@code true} when any dimension is unknown or negative. */ private static boolean isNegativeMultiArraySizePossible(@Nonnull MultiANewArrayInsnNode multiArray, @Nonnull Frame frame) { for (int i = 0; i < multiArray.dims; i++) if (isNegativeSizePossible(peekStack(frame, i))) return true; return false; } /** * @param arrayValue * Array reference. * @param indexValue * Array index. * * @return {@code true} when the index is unknown or outside known bounds of a non-null array. */ private static boolean canArrayIndexThrow(@Nullable ReValue arrayValue, @Nullable ReValue indexValue) { // This would be a NPE instead. if (isReferenceKnownNull(arrayValue)) return false; // If we don't know the index, or we don't know the array length, then we have to assume the index could be out of bounds. if (!(indexValue instanceof IntValue index) || index.value().isEmpty()) return true; if (!(arrayValue instanceof ArrayValue array)) return true; OptionalInt length = array.getFirstDimensionLength(); if (length.isEmpty()) return true; // Check if the index is outside the bounds of the array length. int literalIndex = index.value().getAsInt(); return literalIndex < 0 || literalIndex >= length.getAsInt(); } /** * @param catchType * Caught exception type. * @param value * Reference value. * * @return {@code true} when the reference may trigger a caught {@link NullPointerException}. */ private boolean canThrowNullPointer(@Nonnull String catchType, @Nullable ReValue value) { return isCaughtException(catchType, EX_NPE) && isReferencePossiblyNull(value); } /** * @param type * Thrown exception type. * @param catchType * Caught exception type. * * @return {@code true} when the runtime reference may still match the catch. */ private boolean canReferenceRuntimeTypeMatch(@Nonnull Type type, @Nonnull String catchType) { if (type.getSort() != Type.OBJECT) return false; String typeName = type.getInternalName(); return isCaughtException(catchType, typeName) || inheritanceGraph.isAssignableFrom(typeName, catchType); } /** * @param catchType * Catch type. * @param thrownType * Thrown type. * * @return {@code true} when the catch type can handle the thrown type. */ private boolean isCaughtException(@Nonnull String catchType, @Nonnull String thrownType) { return catchType.equals(thrownType) || inheritanceGraph.isAssignableFrom(catchType, thrownType); } /** * @param broaderType * Possibly broader catch type. * @param narrowerType * Possibly narrower catch type. * * @return {@code true} when the first type catches the same or broader set of exceptions. */ private boolean catchesSameOrBroaderException(@Nullable String broaderType, @Nullable String narrowerType) { if (broaderType == null) return true; if (narrowerType == null) return false; return broaderType.equals(narrowerType) || inheritanceGraph.isAssignableFrom(broaderType, narrowerType); } /** * @param value * Reference candidate. * * @return {@code true} when the value may be {@code null}. */ private static boolean isReferencePossiblyNull(@Nullable ReValue value) { return !(value instanceof ObjectValue object) || !object.isNotNull(); } /** * @param value * Reference candidate. * * @return {@code true} when the value is definitely {@code null}. */ private static boolean isReferenceKnownNull(@Nullable ReValue value) { return value instanceof ObjectValue object && object.isNull(); } /** * Reads a stack value relative to the stack top. * * @param frame * Method frame before an instruction. * @param offsetFromTop * Offset from the stack top. * * @return Stack value, or {@code null} when unavailable. */ @Nullable private static ReValue peekStack(@Nonnull Frame frame, int offsetFromTop) { int index = frame.getStackSize() - 1 - offsetFromTop; if (index < 0) return null; return frame.getStack(index); } /** * @param instructions * Method instructions. * @param label * Boundary label. * * @return Index of the first executable instruction at or after the label. */ private static int codeBoundaryIndex(@Nonnull InsnList instructions, @Nonnull LabelNode label) { AbstractInsnNode current = label; current = AsmInsnUtil.getNextInsn(current); return current == null ? instructions.size() : instructions.indexOf(current); } /** * @param instructions * Method instructions. * @param tryCatch * Try-catch entry. * * @return Snapshot of the try-catch's effective range and handler. */ @Nonnull private static TryCatchState snapshotState(@Nonnull InsnList instructions, @Nonnull TryCatchBlockNode tryCatch) { return new TryCatchState(codeBoundaryIndex(instructions, tryCatch.start), codeBoundaryIndex(instructions, tryCatch.end), codeBoundaryIndex(instructions, tryCatch.handler), tryCatch.type); } /** * @param instructions * Method instructions. * @param tryCatches * Try-catch entries. * * @return Snapshots of all entries in declaration order. */ @Nonnull private static List snapshotStates(@Nonnull InsnList instructions, @Nonnull List tryCatches) { List states = new ArrayList<>(tryCatches.size()); for (TryCatchBlockNode tryCatch : tryCatches) states.add(snapshotState(instructions, tryCatch)); return states; } /** * Simplified try-catch signature used for hashing/comparisons without {@link LabelNode} references. * * @param start * Protected range start. * @param end * Protected range end. * @param handler * Handler range start. * @param type * Handled exception type. Can be {@code null} for catch-all handlers. */ private record TryCatchState(int start, int end, int handler, @Nullable String type) { private boolean covers(@Nonnull TryCatchState other) { return start <= other.start && end >= other.end; } } /** * Simplified range. * * @param start * Protected range start. * @param end * Protected range end. */ private record TryRange(int start, int end) {} /** * Collection of try catch blocks. * * @param blocks * Wrapped list of blocks. * @param seenTypes * Observed types handled by the blocks. */ private record Handlers(@Nonnull List blocks, @Nonnull Set seenTypes) { private Handlers() { this(new ArrayList<>(), new HashSet<>()); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/SourceNameRestorationTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import regexodus.Pattern; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.util.RegexUtil; import software.coley.recaf.util.visitors.SkippingClassVisitor; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; /** * A transformer that creates mappings to rename obfuscated classes based on any remaining {@code SourceFile} attribute. * * @author Matt Coley */ @Dependent public class SourceNameRestorationTransformer implements JvmClassTransformer { @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { // Inner classes will retain the source attribute of their outer class, and // we only want to rename the top-level/outer class. if (initialClassState.isInnerClass()) return; // Extract the source-file attribute contents, see if its reasonable // and then add it to the mappings. initialClassState.getClassReader().accept(new SkippingClassVisitor() { private static final Pattern SOURCE_NAME_PATTERN = RegexUtil.pattern("\\w{1, 50}\\.(?:java|kt)"); @Override public void visitSource(String source, String debug) { if (source == null || source.isBlank() || !SOURCE_NAME_PATTERN.matches(source)) return; String name = initialClassState.getName(); String sourceName = source.substring(0, Math.max(source.lastIndexOf(".java"), source.lastIndexOf(".kt"))); String packageName = initialClassState.getPackageName(); String restoredName = packageName == null ? sourceName : packageName + '/' + sourceName; context.getMappings().addClass(name, restoredName); } }, 0); } @Nonnull @Override public String name() { return "Source name restoration"; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/StaticValueCollectionTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.Dependent; import jakarta.inject.Inject; import org.objectweb.asm.Opcodes; import org.objectweb.asm.Type; import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.FieldInsnNode; import org.objectweb.asm.tree.MethodNode; import org.objectweb.asm.tree.analysis.Frame; import software.coley.collections.Unchecked; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.services.inheritance.InheritanceGraphService; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.services.workspace.WorkspaceManager; import software.coley.recaf.util.analysis.Nullness; import software.coley.recaf.util.analysis.ReAnalyzer; import software.coley.recaf.util.analysis.ReInterpreter; import software.coley.recaf.util.analysis.lookup.GetStaticLookup; import software.coley.recaf.util.analysis.value.IllegalValueException; import software.coley.recaf.util.analysis.value.ReValue; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.HashSet; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; /** * A transformer that collects values of {@code static final} field assignments. *
    *
  • Intended to be used in combination with {@link StaticValueInliningTransformer}.
  • *
  • Can also be used as a {@link GetStaticLookup}
  • *
* * @author Matt Coley */ @Dependent public class StaticValueCollectionTransformer implements JvmClassTransformer, GetStaticLookup { private final Map classValues = new ConcurrentHashMap<>(); private final Map classFinals = new ConcurrentHashMap<>(); private final InheritanceGraphService graphService; private final WorkspaceManager workspaceManager; private InheritanceGraph inheritanceGraph; @Inject public StaticValueCollectionTransformer(@Nonnull WorkspaceManager workspaceManager, @Nonnull InheritanceGraphService graphService) { this.workspaceManager = workspaceManager; this.graphService = graphService; } /** * @param className * Name of class defining the field. * @param fieldName * Field name. * @param fieldDesc * Field descriptor. * * @return Static value wrapper if known, otherwise {@code null}. */ @Nullable public ReValue getStaticValue(@Nonnull String className, @Nonnull String fieldName, @Nonnull String fieldDesc) { StaticValues values = classValues.get(className); if (values == null) return null; return values.get(fieldName, fieldDesc); } @Nonnull @Override public ReValue get(@Nonnull FieldInsnNode field) { ReValue value = getStaticValue(field.owner, field.name, field.desc); if (value == null) { try { return Objects.requireNonNull(ReValue.ofType(Type.getType(field.desc), Nullness.UNKNOWN)); } catch (Exception ex) { // Should never fail since fields cannot have illegal types like primitive void. throw new IllegalStateException(ex); } } return value; } @Override public boolean hasLookup(@Nonnull FieldInsnNode field) { return getStaticValue(field.owner, field.name, field.desc) != null; } @Override public void setup(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace) { inheritanceGraph = graphService.getOrCreateInheritanceGraph(workspace); } @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { StaticValues valuesContainer = new StaticValues(); EffectivelyFinalFields finalContainer = new EffectivelyFinalFields(); // TODO: Make some config options for this // - Option to make unsafe assumptions // - treat all effectively final candidates as actually final // - Option to scan other classes for references to our fields to have more thorough 'effective-final' checking // - will be slower, but it will be opt-in and off by default // Populate initial values based on field's default value attribute for (FieldMember field : initialClassState.getFields()) { if (!field.hasStaticModifier()) continue; // Add to effectively-final container if it is 'static final' // If the field is private add it to the "maybe" effectively-final list, and we'll confirm it later if (field.hasFinalModifier()) finalContainer.add(field.getName(), field.getDescriptor()); else if (field.hasPrivateModifier()) // We can only assume private fields are effectively-final if nothing outside the writes to them. // Any other level of access can be written to by child classes or classes in the same package. finalContainer.addMaybe(field.getName(), field.getDescriptor()); // TODO: As mentioned above, we can add another 'else' case here for non-private fields // but then we need to make sure no other classes write to those fields. So its more computational work... // Skip if there is no default value Object defaultValue = field.getDefaultValue(); if (defaultValue == null) continue; // Skip if the value cannot be mapped to our representation ReValue mappedValue = Unchecked.getOr(() -> ReValue.ofConstant(defaultValue), null); if (mappedValue == null) continue; // Store the value valuesContainer.put(field.getName(), field.getDescriptor(), mappedValue); } // Visit of classes and collect static field values of primitives String className = initialClassState.getName(); if (initialClassState.getDeclaredMethod("", "()V") != null) { ClassNode node = context.getNode(bundle, initialClassState); // Find the static initializer and determine which fields are "effectively-final" MethodNode clinit = null; for (MethodNode method : node.methods) { if ((method.access & Opcodes.ACC_STATIC) != 0 && method.name.equals("") && method.desc.equals("()V")) { clinit = method; } else if (method.instructions != null) { // Any put-static to a field in our class means it is not effectively-final because the method is not the static initializer for (AbstractInsnNode instruction : method.instructions) { if (instruction.getOpcode() == Opcodes.PUTSTATIC && instruction instanceof FieldInsnNode fieldInsn) { // Skip if not targeting our class if (!fieldInsn.owner.equals(className)) continue; String fieldName = fieldInsn.name; String fieldDesc = fieldInsn.desc; finalContainer.removeMaybe(fieldName, fieldDesc); } } } } finalContainer.commitMaybeIntoEffectivelyFinals(); // Only analyze if we see static setters if (clinit != null && hasStaticSetters(clinit)) { try { ReAnalyzer analyzer = context.newAnalyzer(inheritanceGraph, node, clinit); ReInterpreter interpreter = analyzer.getInterpreter(); Frame[] frames = analyzer.analyze(node.name, clinit); AbstractInsnNode[] instructions = clinit.instructions.toArray(); for (int i = 0; i < instructions.length; i++) { AbstractInsnNode instruction = instructions[i]; if (instruction.getOpcode() == Opcodes.PUTSTATIC && instruction instanceof FieldInsnNode fieldInsn) { // Skip if not targeting our class if (!fieldInsn.owner.equals(className)) continue; // Skip if the field is not final, or effectively final String fieldName = fieldInsn.name; String fieldDesc = fieldInsn.desc; if (!finalContainer.contains(fieldName, fieldDesc)) continue; // Merge the static value state Frame frame = frames[i]; ReValue existingValue = valuesContainer.get(fieldName, fieldDesc); ReValue stackValue = frame.getStack(frame.getStackSize() - 1); ReValue merged = existingValue == null ? stackValue : interpreter.merge(existingValue, stackValue); valuesContainer.put(fieldName, fieldDesc, merged); } } // Any static final fields that have not been assigned are assumed to contain their default values. valuesContainer.commitRemainingAsDefaults(finalContainer); } catch (Throwable t) { throw new TransformationException("Error encountered when computing static constants", t); } } } else { // If there is no static initializer, then assume the values are defaults (o for primitives, null for objects) try { valuesContainer.commitRemainingAsDefaults(finalContainer); } catch (IllegalValueException ex) { throw new TransformationException("Error encountered when computing default constant values", ex); } } // Record the values for the target class if we recorded at least one value if (!valuesContainer.staticFieldValues.isEmpty()) classValues.put(className, valuesContainer); } @Nonnull @Override public String name() { return "Static value collection"; } /** * @param method * Method to check for {@link Opcodes#PUTSTATIC} use. * * @return {@code true} when the method has a {@link Opcodes#PUTSTATIC} instruction. */ private static boolean hasStaticSetters(@Nonnull MethodNode method) { if (method.instructions == null) return false; for (AbstractInsnNode abstractInsnNode : method.instructions) if (abstractInsnNode.getOpcode() == Opcodes.PUTSTATIC) return true; return false; } /** * @param name * Field name. * @param desc * Field descriptor. * * @return Field key. */ @Nonnull private static String key(@Nonnull String name, @Nonnull String desc) { return name + ':' + desc; } /** * Wrapper/utility for field finality storage/lookups. */ private static class EffectivelyFinalFields { private Set finalFieldKeys; private Set maybeFinalFieldKeys; /** * Add a {@code static final} field. * * @param name * Field name. * @param desc * Field descriptor. */ public void add(@Nonnull String name, @Nonnull String desc) { if (finalFieldKeys == null) finalFieldKeys = new HashSet<>(); finalFieldKeys.add(key(name, desc)); } /** * Add a {@code static} field that may be effectively final. * * @param name * Field name. * @param desc * Field descriptor. */ public void addMaybe(@Nonnull String name, @Nonnull String desc) { if (maybeFinalFieldKeys == null) maybeFinalFieldKeys = new HashSet<>(); maybeFinalFieldKeys.add(key(name, desc)); } /** * Remove a field from being considered possibly effectively final. * * @param name * Field name. * @param desc * Field descriptor. */ public void removeMaybe(@Nonnull String name, @Nonnull String desc) { if (maybeFinalFieldKeys != null) maybeFinalFieldKeys.remove(key(name, desc)); } /** * Commit all possible effectively final fields into the final fields set. */ public void commitMaybeIntoEffectivelyFinals() { if (maybeFinalFieldKeys != null) if (finalFieldKeys == null) finalFieldKeys = new HashSet<>(maybeFinalFieldKeys); else finalFieldKeys.addAll(maybeFinalFieldKeys); } /** * @param name * Field name. * @param desc * Field descriptor. * * @return {@code true} when the field is {@code final} or effectively {@code final}. */ public boolean contains(@Nonnull String name, @Nonnull String desc) { if (finalFieldKeys == null) return false; return finalFieldKeys.contains(key(name, desc)); } } /** * Wrapper/utility for field value storage/lookups. */ private static class StaticValues { private final Map staticFieldValues = new ConcurrentHashMap<>(); private void put(@Nonnull String name, @Nonnull String desc, @Nonnull ReValue value) { staticFieldValues.put(key(name, desc), value); } @Nullable private ReValue get(@Nonnull String name, @Nonnull String desc) { return staticFieldValues.get(key(name, desc)); } private void commitRemainingAsDefaults(@Nonnull EffectivelyFinalFields finalFields) throws IllegalValueException { if (finalFields.finalFieldKeys == null) return; // By the point this is called, the final fields container will have committed any "maybe" candidates // that are valid to being actually final. Thus, if we see anything in the final field set, we will // initialize them with default values here. for (String key : finalFields.finalFieldKeys) if (!staticFieldValues.containsKey(key)) { Type fieldType = Type.getType(key.substring(key.indexOf(':') + 1)); staticFieldValues.put(key, ReValue.ofTypeDefaultValue(fieldType)); } } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/StaticValueInliningTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import org.objectweb.asm.Opcodes; import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.FieldInsnNode; import org.objectweb.asm.tree.InsnNode; import org.objectweb.asm.tree.LdcInsnNode; import org.objectweb.asm.tree.MethodNode; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.transform.ClassTransformer; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.util.AsmInsnUtil; import software.coley.recaf.util.analysis.value.DoubleValue; import software.coley.recaf.util.analysis.value.FloatValue; import software.coley.recaf.util.analysis.value.IntValue; import software.coley.recaf.util.analysis.value.LongValue; import software.coley.recaf.util.analysis.value.ObjectValue; import software.coley.recaf.util.analysis.value.ReValue; import software.coley.recaf.util.analysis.value.StringValue; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.Collections; import java.util.Set; /** * A transformer that inlines values from {@link StaticValueCollectionTransformer}. * * @author Matt Coley */ @Dependent public class StaticValueInliningTransformer implements JvmClassTransformer { @Override @SuppressWarnings("OptionalGetWithoutIsPresent") public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { var staticValueCollector = context.getJvmTransformer(StaticValueCollectionTransformer.class); boolean dirty = false; ClassNode node = context.getNode(bundle, initialClassState); for (MethodNode method : node.methods) { // Skip static initializer and abstract methods if (method.name.contains("") || method.instructions == null) continue; for (AbstractInsnNode instruction : method.instructions) { if (instruction instanceof FieldInsnNode fieldInsn) { // Get the known value of the static field ReValue value = staticValueCollector.getStaticValue(fieldInsn.owner, fieldInsn.name, fieldInsn.desc); if (value == null) continue; // Replace static get calls with their known values if (value.hasKnownValue()) { switch (value) { case IntValue intValue -> { method.instructions.set(instruction, AsmInsnUtil.intToInsn(intValue.value().getAsInt())); dirty = true; } case LongValue longValue -> { method.instructions.set(instruction, AsmInsnUtil.longToInsn(longValue.value().getAsLong())); dirty = true; } case DoubleValue doubleValue -> { method.instructions.set(instruction, AsmInsnUtil.doubleToInsn(doubleValue.value().getAsDouble())); dirty = true; } case FloatValue floatValue -> { method.instructions.set(instruction, AsmInsnUtil.floatToInsn((float) floatValue.value().getAsDouble())); dirty = true; } case StringValue stringValue -> { method.instructions.set(instruction, new LdcInsnNode(stringValue.getText().get())); dirty = true; } default -> { // no-op } } } else if (value == ObjectValue.VAL_OBJECT_NULL) { method.instructions.set(instruction, new InsnNode(Opcodes.ACONST_NULL)); dirty = true; } } } } // Record transformed class if we made any changes if (dirty) context.setNode(bundle, initialClassState, node); } @Nonnull @Override public String name() { return "Static value inlining"; } @Nonnull @Override public Set> dependencies() { return Collections.singleton(StaticValueCollectionTransformer.class); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/UnknownAttributeRemovingTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.FieldNode; import org.objectweb.asm.tree.MethodNode; import org.objectweb.asm.tree.RecordComponentNode; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.util.visitors.UnknownAttributeRemovingVisitor; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; /** * A transformer that removes unknown attributes. * * @author Matt Coley */ @Dependent public class UnknownAttributeRemovingTransformer implements JvmClassTransformer { @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { if (context.isNode(bundle, initialClassState)) { ClassNode node = context.getNode(bundle, initialClassState); if (node.attrs != null) node.attrs.clear(); for (FieldNode field : node.fields) if (field.attrs != null) field.attrs.clear(); for (MethodNode method : node.methods) if (method.attrs != null) method.attrs.clear(); if (node.recordComponents != null) for (RecordComponentNode recordComponent : node.recordComponents) if (recordComponent.attrs != null) recordComponent.attrs.clear(); } else { ClassReader reader = new ClassReader(context.getBytecode(bundle, initialClassState)); ClassWriter writer = new ClassWriter(reader, 0); reader.accept(new UnknownAttributeRemovingVisitor(writer), 0); context.setBytecode(bundle, initialClassState, writer.toByteArray()); } } @Nonnull @Override public String name() { return "Unknown attribute removal"; } @Override public boolean pruneAfterNoWork() { // Other transformers should not introduce junk attributes, // so once the work is done there is no need to re-process classes on following passes. return true; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/VariableFoldingTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import it.unimi.dsi.fastutil.ints.Int2ObjectArrayMap; import it.unimi.dsi.fastutil.ints.Int2ObjectMap; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import jakarta.inject.Inject; import org.objectweb.asm.Type; import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.IincInsnNode; import org.objectweb.asm.tree.InsnList; import org.objectweb.asm.tree.InsnNode; import org.objectweb.asm.tree.MethodNode; import org.objectweb.asm.tree.VarInsnNode; import org.objectweb.asm.tree.analysis.Frame; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.services.inheritance.InheritanceGraphService; import software.coley.recaf.services.transform.ClassTransformer; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.util.AccessFlag; import software.coley.recaf.util.Types; import software.coley.recaf.util.analysis.value.ReValue; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Deque; import java.util.HashSet; import java.util.List; import java.util.NavigableSet; import java.util.Set; import java.util.TreeSet; import static java.util.Collections.emptyList; import static java.util.Collections.emptyNavigableSet; import static software.coley.recaf.util.AsmInsnUtil.*; /** * A transformer that folds redundant variable use. *
* You should use {@link OpaqueConstantFoldingTransformer} after using this for {@code POP} cleanup. * * @author Matt Coley */ @Dependent public class VariableFoldingTransformer implements JvmClassTransformer { private final InheritanceGraphService graphService; private InheritanceGraph inheritanceGraph; @Inject public VariableFoldingTransformer(@Nonnull InheritanceGraphService graphService) { this.graphService = graphService; } @Override public void setup(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace) { inheritanceGraph = graphService.getOrCreateInheritanceGraph(workspace); } @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { boolean dirty = false; String className = initialClassState.getName(); ClassNode node = context.getNode(bundle, initialClassState); for (MethodNode method : node.methods) { // Skip if abstract. InsnList instructions = method.instructions; if (instructions == null) continue; // Build successor and predecessor maps modeling control flow. Int2ObjectMap> successorMap = new Int2ObjectArrayMap<>(); Int2ObjectMap> predecessorMap = new Int2ObjectArrayMap<>(); populateFlowMaps(method, successorMap, predecessorMap); // Compute liveness using iterative backward data-flow analysis. int size = instructions.size(); List> inLive = new ArrayList<>(size); List> outLive = new ArrayList<>(size); populateLiveness(method, inLive, outLive, successorMap, predecessorMap); // Populate local access state. Int2ObjectMap accessStates = new Int2ObjectArrayMap<>(); populateVariableAccessStates(method, accessStates); // Fold in reverse order. Frame[] frames = context.analyze(inheritanceGraph, node, method); for (int i = size - 1; i >= 0; i--) { AbstractInsnNode insn = instructions.get(i); int op = insn.getOpcode(); // Skip if dead code (unreachable code not analyzed). Frame frame = frames[i]; if (frame == null) continue; if (isVarLoad(op) && insn instanceof VarInsnNode vin) { // Fold constant loads. ReValue val = frame.getLocal(vin.var); if (val != null && val.hasKnownValue()) { AbstractInsnNode replacement = OpaqueConstantFoldingTransformer.toInsn(val); if (replacement != null) { instructions.set(insn, replacement); dirty = true; } } } else { // Get variable index and type from store/iinc. int var; Type type; if (insn instanceof VarInsnNode vin) { var = vin.var; type = getTypeForVarInsn(vin); } else if (insn instanceof IincInsnNode iinc) { var = iinc.var; type = Type.INT_TYPE; } else { continue; } // Remove dead stores/iinc. Set liveAfter = outLive.get(i); if (!liveAfter.contains(var)) { if (op == IINC) { instructions.set(insn, new InsnNode(NOP)); } else { AbstractInsnNode prev = insn.getPrevious(); if (OpaqueConstantFoldingTransformer.isSupportedValueProducer(prev)) { instructions.set(prev, new InsnNode(NOP)); instructions.set(insn, new InsnNode(NOP)); } else { instructions.set(insn, new InsnNode(type.getSize() == 2 ? POP2 : POP)); } } dirty = true; } } } // Handle redundant variable copies. int[] keys = accessStates.keySet().toIntArray(); for (int keyY : keys) { LocalAccessState stateY = accessStates.get(keyY); int slotY = slotFromKey(keyY); int typeSort = typeSortFromKey(keyY); // Redundancy only applies if there is a single write to Y. NavigableSet writesY = stateY.getWrites(); if (writesY.size() != 1) continue; // Get the single write instruction to Y. LocalAccess writeAccessY = writesY.first(); AbstractInsnNode writeInsnY = writeAccessY.instruction; if (!(writeInsnY instanceof VarInsnNode vinY && isVarStore(vinY.getOpcode()))) continue; // Check if the prior instruction is the copy source of 'load x'. AbstractInsnNode prevInsn = writeInsnY.getPrevious(); if (!(prevInsn instanceof VarInsnNode vinX && isVarLoad(vinX.getOpcode()))) continue; // Check if the source variable is different and has a known state. int slotX = vinX.var; int keyX = key(slotX, typeSort); if (slotX == slotY || !accessStates.containsKey(keyX)) continue; // Check if the store to Y is redundant. if (isRedundantStore(accessStates, instructions, successorMap, writeAccessY.offset, slotX, slotY, typeSort)) { // Replace usages. replaceRedundantVariableUsage(instructions, slotX, slotY, typeSort); // Update state. stateY.getWrites().clear(); stateY.getReads().clear(); // Replace store with POP. Type varType = Types.fromSort(typeSort); instructions.set(writeInsnY, new InsnNode(varType.getSize() == 2 ? POP2 : POP)); dirty = true; } } } if (dirty) context.setNode(bundle, initialClassState, node); } /** * Populate variable liveness for the method. *

* Liveness is the set of variables that are live at each instruction. * A good reference for this can be found here. * * @param method * Method to analyze. * @param inLive * Output in-live sets. * @param outLive * Output out-live sets. * @param successorMap * Flow successor map. * @param predecessorMap * Flow predecessor map. */ private static void populateLiveness(@Nonnull MethodNode method, @Nonnull List> inLive, @Nonnull List> outLive, @Nonnull Int2ObjectMap> successorMap, @Nonnull Int2ObjectMap> predecessorMap) { // Initialize empty live sets for every instruction. InsnList instructions = method.instructions; int size = instructions.size(); for (int i = 0; i < size; i++) { inLive.add(new HashSet<>()); outLive.add(new HashSet<>()); } // Assume we need to check every instruction once. Deque unprocessed = new ArrayDeque<>(); for (int i = 0; i < size; i++) unprocessed.add(i); // Iterate until no changes occur. while (!unprocessed.isEmpty()) { // Next instruction to process. int i = unprocessed.poll(); AbstractInsnNode insn = instructions.get(i); int op = insn.getOpcode(); // A variable is live after the instruction if it is going to be used later, which can // be determined by looking at all successor instructions. Set out = outLive.get(i); out.clear(); for (int s : successorMap.getOrDefault(i, emptyList())) out.addAll(inLive.get(s)); // Compute gen/kill sets for the instruction. // - Gen: Variables read by the instruction. // - Kill: Variables written to by the instruction. Set gen = new HashSet<>(); Set kill = new HashSet<>(); int var; if (insn instanceof VarInsnNode vin) { var = vin.var; if (isVarLoad(op)) gen.add(var); if (isVarStore(op)) kill.add(var); } else if (insn instanceof IincInsnNode iinc) { // IINC both reads and writes the variable. var = iinc.var; gen.add(var); kill.add(var); } // Anything read by this instruction (gen) is live before it. // Anything written to by this instruction (kill) is not live going forward. // Everything else live after the instruction (out) is also live before it. Set newIn = new HashSet<>(gen); Set temp = new HashSet<>(out); temp.removeAll(kill); newIn.addAll(temp); // If the in-live set changed we discovered new live variables. // We need to re-check all instructions that can reach this one. if (!newIn.equals(inLive.get(i))) { inLive.set(i, newIn); // Queue predecessors for re-check. unprocessed.addAll(predecessorMap.getOrDefault(i, emptyList())); } } } /** * Populate variable access states for the method. *

* This tracks when variables are read from and written to, which is necessary * for determining when variable copies are redundant and can be folded. * * @param method * Method to analyze. * @param accessStates * Output variable access states. */ private static void populateVariableAccessStates(@Nonnull MethodNode method, @Nonnull Int2ObjectMap accessStates) { InsnList instructions = method.instructions; int size = instructions.size(); boolean isStatic = AccessFlag.isStatic(method.access); int paramSlot = isStatic ? 0 : 1; // Add implicit 'this' if non-static. if (!isStatic) { LocalAccessState thisState = new LocalAccessState(0); thisState.addWrite(-1, new VarInsnNode(ASTORE, 0)); accessStates.put(key(0, Type.OBJECT), thisState); } // Add explicit parameters. for (Type argType : Type.getArgumentTypes(method.desc)) { LocalAccessState paramState = new LocalAccessState(paramSlot); paramState.addWrite(-1, createVarStore(paramSlot, argType)); accessStates.put(key(paramSlot, argType.getSort()), paramState); paramSlot += argType.getSize(); } // Populate accesses from instructions. for (int i = 0; i < size; i++) { AbstractInsnNode insn = instructions.get(i); int op = insn.getOpcode(); if (insn instanceof VarInsnNode vin) { // Variable load/store. Type type = getTypeForVarInsn(vin); LocalAccessState state = accessStates.computeIfAbsent(key(vin.var, type.getSort()), _ -> new LocalAccessState(vin.var)); if (isVarLoad(op)) state.addRead(i, vin); else if (isVarStore(op)) state.addWrite(i, vin); } else if (op == IINC && insn instanceof IincInsnNode iinc) { // Increment is both a read and write. LocalAccessState state = accessStates.computeIfAbsent(key(iinc.var, Type.INT), _ -> new LocalAccessState(iinc.var)); state.addRead(i, iinc); state.addWrite(i, iinc); } } } /** * @param accessStates * Variable access states. * @param instructions * Method instructions. * @param successorMap * Control flow successor map. * @param storeIndexY * Instructions index of the store to Y. * @param slotX * The original variable index. * @param slotY * The target variable index to check for redundancy. * @param typeSort * The variable's type sort. See {@link Type#getSort()}. * * @return {@code true} when the store to Y is redundant and can be replaced safely. */ private static boolean isRedundantStore(@Nonnull Int2ObjectMap accessStates, @Nonnull InsnList instructions, @Nonnull Int2ObjectMap> successorMap, int storeIndexY, int slotX, int slotY, int typeSort) { LocalAccessState stateX = accessStates.get(key(slotX, typeSort)); LocalAccessState stateY = accessStates.get(key(slotY, typeSort)); if (stateX == null || stateY == null) return false; // Single write to Y. NavigableSet writesY = stateY.getWrites(); if (writesY.size() != 1) return false; LocalAccess writeY = writesY.first(); if (writeY.offset != storeIndexY) return false; // Prior is load from X. AbstractInsnNode storeInsnY = instructions.get(storeIndexY); AbstractInsnNode prev = storeInsnY.getPrevious(); if (!(prev instanceof VarInsnNode vinX && vinX.var == slotX && isMatchingLoad(typeSort, vinX.getOpcode()))) return false; // No intervening writes to X or Y between load X and store Y. for (int j = instructions.indexOf(prev) + 1; j < storeIndexY; j++) { AbstractInsnNode ins = instructions.get(j); if (ins instanceof VarInsnNode vin) { if ((vin.var == slotX || vin.var == slotY) && isVarStore(vin.getOpcode())) return false; } else if (ins instanceof IincInsnNode iinc) { if (iinc.var == slotX || iinc.var == slotY) return false; } } // X defined before Y. NavigableSet writesX = stateX.getWrites(); if (writesX.isEmpty()) return false; if (writesX.first().offset >= storeIndexY) return false; // Check no updates to X on any path from store Y to reads of Y. // -1 unvisited, 0 unchanged, 1 changed int size = instructions.size(); int[] state = new int[size]; Arrays.fill(state, -1); // Add initial successors of store Y to the queue and mark // them as unchanged (0) since we haven't seen any updates to X yet. Deque unprocessed = new ArrayDeque<>(); for (int s : successorMap.getOrDefault(storeIndexY, emptyList())) { state[s] = 0; unprocessed.add(s); } // Iteratively propagate state until we reach all reads of Y. while (!unprocessed.isEmpty()) { int i = unprocessed.poll(); AbstractInsnNode insn = instructions.get(i); int op = insn.getOpcode(); boolean isXWrite = (isVarStore(op) && ((VarInsnNode) insn).var == slotX) || (op == IINC && ((IincInsnNode) insn).var == slotX); int newState = state[i]; if (isXWrite) newState = 1; for (int s : successorMap.getOrDefault(i, emptyList())) { int oldState = state[s]; int newStatePropagated = Math.max(oldState == -1 ? newState : oldState, newState); if (newStatePropagated != oldState) { state[s] = newStatePropagated; unprocessed.add(s); } } } // If any read of Y is reachable from store Y without seeing an // update to X (state 0 - unchanged), then the store is not redundant. for (LocalAccess access : stateY.getReads()) if (state[access.offset] == 1) return false; return true; } /** * Replaces usage of the redundant variable with the original variable. * * @param instructions * Instructions to modify. * @param slotX * The original variable index. * @param slotY * The target variable index that is redundant. * @param typeSort * The variable's type sort. See {@link Type#getSort()}. */ private static void replaceRedundantVariableUsage(@Nonnull InsnList instructions, int slotX, int slotY, int typeSort) { AbstractInsnNode replacement = createVarLoad(slotX, typeSort); for (int i = 0; i < instructions.size(); i++) { AbstractInsnNode insn = instructions.get(i); if (insn instanceof VarInsnNode vin && vin.var == slotY && isVarLoad(vin.getOpcode())) { instructions.set(insn, replacement.clone(null)); } else if (insn instanceof IincInsnNode iinc && iinc.var == slotY) { iinc.var = slotX; } } } /** * @param typeSort * The variable's type sort. See {@link Type#getSort()}. * @param opcode * Variable load opcode. * * @return {@code true} when opcode matches expected type. */ private static boolean isMatchingLoad(int typeSort, int opcode) { return switch (typeSort) { case Type.INT -> opcode == ILOAD; case Type.FLOAT -> opcode == FLOAD; case Type.LONG -> opcode == LLOAD; case Type.DOUBLE -> opcode == DLOAD; case Type.OBJECT, Type.ARRAY -> opcode == ALOAD; default -> false; }; } @Nonnull @Override public Set> recommendedSuccessors() { // This transformer results in the creation of a lot of POP/POP2 instructions. // The stack-operation folding transformer can clean up afterward. return Collections.singleton(OpaqueConstantFoldingTransformer.class); } @Nonnull @Override public String name() { return "Variable folding"; } /** * @param slot * Variable index. * @param typeSort * Variable type sort. See {@link Type#getSort()}. * * @return Key of typed variable. */ private static int key(int slot, int typeSort) { return slot | (typeSort << 16); } /** * @param key * Key of typed variable. * * @return Variable index stored in the key. */ private static int slotFromKey(int key) { return key & 0xFFFF; } /** * @param key * Key of typed variable. * * @return Variable type sort. See {@link Type#getSort()}. */ private static int typeSortFromKey(int key) { return key >> 16; } /** * State tracking for when variables are read from and written to. *
* These states are keyed by {@link #key(int, int)} which ensures that multiple types can target the * same variable index without issue. */ private static class LocalAccessState { private final int index; private NavigableSet reads; private NavigableSet writes; private LocalAccessState(int index) { this.index = index; } public void addRead(int offset, @Nonnull AbstractInsnNode instruction) { if (reads == null) reads = new TreeSet<>(); reads.add(new LocalAccess(offset, instruction)); } public void addWrite(int offset, @Nonnull AbstractInsnNode instruction) { if (writes == null) writes = new TreeSet<>(); writes.add(new LocalAccess(offset, instruction)); } @Nonnull public NavigableSet getReads() { if (reads == null) return emptyNavigableSet(); return reads; } @Nonnull public NavigableSet getWrites() { if (writes == null) return emptyNavigableSet(); return writes; } @Override public String toString() { return "LocalAccessState{" + "index=" + index + ", reads=" + reads + ", writes=" + writes + '}'; } } /** * Model of an instruction at some code offset that accesses a variable. * * @param offset * Instruction index in {@link MethodNode#instructions}. * @param instruction * Instruction accessing a local variable. */ private record LocalAccess(int offset, @Nonnull AbstractInsnNode instruction) implements Comparable { @Override public int compareTo(@Nonnull LocalAccess o) { return Integer.compare(offset, o.offset); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/generic/VariableTableNormalizingTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.generic; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import org.objectweb.asm.Type; import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.InsnList; import org.objectweb.asm.tree.LabelNode; import org.objectweb.asm.tree.LocalVariableNode; import org.objectweb.asm.tree.MethodNode; import org.objectweb.asm.tree.ParameterNode; import org.objectweb.asm.tree.VarInsnNode; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.util.AccessFlag; import software.coley.recaf.util.AsmInsnUtil; import software.coley.recaf.util.Types; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.TreeMap; /** * Replaces all local variables with basic patterns. * * @author Matt Coley */ @Dependent public class VariableTableNormalizingTransformer implements JvmClassTransformer { @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { boolean dirty = false; String className = initialClassState.getName(); ClassNode node = context.getNode(bundle, initialClassState); for (MethodNode method : node.methods) { Type[] argumentTypes = Type.getMethodType(method.desc).getArgumentTypes(); boolean isStatic = AccessFlag.isStatic(method.access); int slot = isStatic ? 0 : 1; InsnList instructions = method.instructions; if (instructions == null) { List parameters = new ArrayList<>(argumentTypes.length); for (Type argumentType : argumentTypes) { parameters.add(new ParameterNode("param" + slot, 0)); slot += argumentType.getSize(); } if (!Objects.equals(parameters, method.parameters)) { method.parameters = parameters; method.localVariables = null; dirty = true; } } else { // Its easier just to add labels than to trust that each method // has them in valid locations to span the whole method. LabelNode start = new LabelNode(); LabelNode end = new LabelNode(); method.instructions.insert(start); method.instructions.add(end); // Populate map of: // variable index ---> variable name & type Map slotToTempVariable = new TreeMap<>(); if (!isStatic) { slotToTempVariable.put(0, new NameType("this", Type.getObjectType(initialClassState.getName()))); } for (Type argumentType : argumentTypes) { slotToTempVariable.put(slot, new NameType("param" + slot, argumentType)); slot += argumentType.getSize(); } for (AbstractInsnNode insn : method.instructions) { if (insn instanceof VarInsnNode varInsn) { int varSlot = varInsn.var; slotToTempVariable.computeIfAbsent(varSlot, v -> { Type varType = AsmInsnUtil.getTypeForVarInsn(varInsn); return new NameType("v" + v, varType); }); } } // Flatten map to list, check if we already have matching variables. List newNameTypes = slotToTempVariable.values().stream().toList(); List existingNameTypes = method.localVariables == null ? Collections.emptyList() : method.localVariables.stream() .filter(l -> Types.isValidDesc(l.desc)) .map(l -> new NameType(l.name, Type.getType(l.desc))) .toList(); if (!Objects.equals(newNameTypes, existingNameTypes)) { // Not a match, replace what was found. List variables = slotToTempVariable.entrySet().stream() .map(e -> { int varSlot = e.getKey(); NameType nameType = e.getValue(); return new LocalVariableNode(nameType.name(), nameType.type().getDescriptor(), null, start, end, varSlot); }).toList(); method.parameters = null; method.localVariables = variables; dirty = true; } } } if (dirty) context.setNode(bundle, initialClassState, node); } @Nonnull @Override public String name() { return "Variable table normalization"; } private record NameType(@Nonnull String name, @Nonnull Type type) {} } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/transform/specific/DashOpaqueSeedFoldingTransformer.java ================================================ package software.coley.recaf.services.deobfuscation.transform.specific; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.InsnList; import org.objectweb.asm.tree.InsnNode; import org.objectweb.asm.tree.MethodInsnNode; import org.objectweb.asm.tree.MethodNode; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.services.transform.JvmClassTransformer; import software.coley.recaf.services.transform.JvmTransformerContext; import software.coley.recaf.services.transform.TransformationException; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.List; import static org.objectweb.asm.Opcodes.ICONST_5; import static org.objectweb.asm.Opcodes.IRETURN; /** * A transformer that folds opaque number providers in DashO obfuscated samples. * * @author Matt Coley */ @Dependent public class DashOpaqueSeedFoldingTransformer implements JvmClassTransformer { @Override public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException { // The generated DashO class has two methods: // - Seed supplier // - String decryptor List methods = initialClassState.getMethods(); if (methods.size() != 2) return; // Must have one method that is the seed getter. if (methods.stream().noneMatch(m -> m.hasStaticModifier() && m.hasPublicModifier() && m.getDescriptor().equals("()I"))) return; // Must have one method that is the string decryptor. if (methods.stream().noneMatch(m -> m.hasStaticModifier() && m.hasPublicModifier() && m.getDescriptor().matches("\\(.+\\)Ljava/lang/String;"))) return; // Take the seed supplier and fold its return value. ClassNode node = context.getNode(bundle, initialClassState); for (MethodNode method : node.methods) { if (!method.desc.equals("()I")) continue; // Skip if abstract. InsnList instructions = method.instructions; if (instructions == null) continue; // Sanity check existence of initial pattern. // new Random().nextInt(X) + 1 boolean found = false; for (AbstractInsnNode insn : instructions) { if (insn instanceof MethodInsnNode min) { if (min.owner.equals("java/util/Random") && min.name.equals("nextInt") && min.desc.equals("(I)I")) { found = true; break; } } } if (!found) continue; // Just make it return a positive number. // All observed usages assume the value is positive and use opaque predicates such as: // n * X % n != 0 // Where X is some constant and n is the seed value. if (method.tryCatchBlocks != null) method.tryCatchBlocks.clear(); instructions.clear(); instructions.add(new InsnNode(ICONST_5)); instructions.add(new InsnNode(IRETURN)); // Update once we're done with this method. context.setNode(bundle, initialClassState, node); return; } } @Nonnull @Override public String name() { return "DashO Opaque Seed Folding"; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/file/RecafDirectoriesConfig.java ================================================ package software.coley.recaf.services.file; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.slf4j.Logger; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.ConfigContainer; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.launch.LaunchCommand; import software.coley.recaf.util.ExcludeFromJacocoGeneratedReport; import software.coley.recaf.util.IOUtil; import software.coley.recaf.util.PlatformType; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; /** * Config for common paths for Recaf. * * @author Matt Coley */ @ApplicationScoped @ExcludeFromJacocoGeneratedReport(justification = "We do not access the config directories in tests (avoiding IO is preferred anyways)") public class RecafDirectoriesConfig extends BasicConfigContainer implements ConfigContainer { private static final Logger logger = Logging.get(RecafDirectoriesConfig.class); private final Path baseDirectory = createBaseDirectory(); private final Path agentDirectory = resolveDirectory("agent"); private final Path configDirectory = resolveDirectory("config"); private final Path logsDirectory = resolveDirectory("logs"); private final Path pluginDirectory = resolveDirectory("plugins"); private final Path styleDirectory = resolveDirectory("style"); private final Path scriptsDirectory = resolveDirectory("scripts"); private final Path tempDirectory = resolveDirectory("temp"); private Path currentLog; @Inject public RecafDirectoriesConfig() { super(ConfigGroups.SERVICE_IO, "directories" + CONFIG_SUFFIX); setupLocalTempDir(); } /** * @param currentLog * Path to current log-file. */ public void initCurrentLogPath(@Nonnull Path currentLog) { if (this.currentLog == null) this.currentLog = currentLog; } /** * @return Path to current log-file. */ @Nonnull public Path getCurrentLogPath() { return currentLog; } /** * @return Base Recaf directory. */ @Nonnull public Path getBaseDirectory() { return baseDirectory; } /** * @return Directory where agent jars are stored. */ @Nonnull public Path getAgentDirectory() { return agentDirectory; } /** * @return Directory where configuration is stored. */ @Nonnull public Path getConfigDirectory() { return configDirectory; } /** * @return Directory where old logs are stored. */ @Nonnull public Path getLogsDirectory() { return logsDirectory; } /** * @return Directory where plugins are stored. */ @Nonnull public Path getPluginDirectory() { return pluginDirectory; } /** * Set via {@link LaunchCommand} to facilitate plugin development. Usually not set otherwise. * * @return Directory where extra plugins are stored. Can be {@code null}. */ @Nullable public Path getExtraPluginDirectory() { String pathProperty = System.getProperty("RECAF_EXTRA_PLUGINS"); if (pathProperty == null) return null; Path path = Paths.get(pathProperty); if (Files.isDirectory(path)) return path; return null; } /** * @return Directory where disabled plugins are stored. */ @Nonnull public Path getDisabledPluginDirectory() { return getPluginDirectory().resolve("disabled"); } /** * @return Directory where additional stylesheets are stored. */ @Nonnull public Path getStyleDirectory() { return styleDirectory; } /** * @return Directory where scripts are stored. */ @Nonnull public Path getScriptsDirectory() { return scriptsDirectory; } /** * @return Directory where temporary files are stored. */ @Nonnull public Path getTempDirectory() { return tempDirectory; } @Nonnull private Path resolveDirectory(@Nonnull String dir) { Path path = baseDirectory.resolve(dir); try { Files.createDirectories(path); } catch (IOException ex) { logger.error("Could not create Recaf directory: " + dir, ex); } return path; } private void setupLocalTempDir() { // If it does not exist yet, make it. if (!Files.isDirectory(tempDirectory)) { try { Files.createDirectories(tempDirectory); } catch (IOException ex) { logger.error("Failed creating temp directory", ex); } } // When we shut down, remove all files inside of it. Runtime.getRuntime().addShutdownHook(new Thread(() -> { try { if (Files.isDirectory(tempDirectory)) IOUtil.cleanDirectory(tempDirectory); } catch (IOException ex) { logger.error("Failed clearing temp directory", ex); } })); } /** * @return Root directory for storing Recaf data. */ @Nonnull public static Path createBaseDirectory() { // Try system property first String recafDir = System.getProperty("RECAF_DIR"); if (recafDir == null) // Next try looking for an environment variable recafDir = System.getenv("RECAF"); if (recafDir != null) return Paths.get(recafDir); // Otherwise put it in the system's config directory Path dir = getSystemConfigDir(); if (dir == null) throw new IllegalStateException("Failed to determine config directory for: " + System.getProperty("os.name")); return dir.resolve("Recaf"); } /** * @return Root config directory for the current OS. */ @Nullable private static Path getSystemConfigDir() { if (PlatformType.isWindows()) { return Paths.get(System.getenv("APPDATA")); } else if (PlatformType.isMac()) { // Mac-OS paths: // https://developer.apple.com/library/archive/qa/qa1170/_index.html return Paths.get(System.getProperty("user.home") + "/Library/Application Support"); } else if (PlatformType.isLinux()) { // $XDG_CONFIG_HOME or $HOME/.config String xdgConfigHome = System.getenv("XDG_CONFIG_HOME"); if (xdgConfigHome != null) return Paths.get(xdgConfigHome); return Paths.get(System.getProperty("user.home") + "/.config"); } return null; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/inheritance/ClassPathNodeProvider.java ================================================ package software.coley.recaf.services.inheritance; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.workspace.model.Workspace; import java.util.HashMap; import java.util.Map; import java.util.stream.Stream; /** * Provider of class path nodes. * * @author xDark */ sealed interface ClassPathNodeProvider { /** * @param name * Class name to look up. * * @return Path node for the class with the given name, or {@code null} if no such class exists in the provider. */ @Nullable ClassPathNode getNode(@Nonnull String name); /** * Create a cached provider that contains all nodes from the workspace at the time of creation. * * @param workspace * Workspace to cache nodes from. * * @return Provider that caches all nodes from the workspace at the time of creation. */ static ClassPathNodeProvider.Cached cache(@Nonnull Workspace workspace) { Stream stream = workspace.classesStream(); Map nodes = new HashMap<>(4096); stream.forEach(classPathNode -> { nodes.putIfAbsent(classPathNode.getValue().getName(), classPathNode); }); return new Cached(Map.copyOf(nodes)); } /** * Provider that looks up nodes directly from the workspace. * This is not recommended for repeated lookups, but it is useful for one-off lookups or when the workspace is expected to be changing frequently. * * @param workspace * Workspace to look up nodes from. */ record Live(@Nonnull Workspace workspace) implements ClassPathNodeProvider { @Nullable @Override public ClassPathNode getNode(@Nonnull String name) { return workspace.findClass(name); } } /** * Provider that caches all nodes from the workspace at the time of creation. * This is recommended for repeated lookups, but it is not suitable for workspaces that are expected to be changing frequently. * * @param nodes * Map of class names to their corresponding path nodes. This map is expected to be immutable. * * @see #cache(Workspace) */ record Cached(@Nonnull Map nodes) implements ClassPathNodeProvider { int size() { return nodes.size(); } @Nullable @Override public ClassPathNode getNode(@Nonnull String name) { return nodes.get(name); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/inheritance/InheritanceGraph.java ================================================ package software.coley.recaf.services.inheritance; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.AndroidClassInfo; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.StubClassInfo; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.path.ResourcePathNode; import software.coley.recaf.services.mapping.MappingApplicationListener; import software.coley.recaf.services.mapping.MappingListeners; import software.coley.recaf.services.mapping.MappingResults; import software.coley.recaf.services.workspace.WorkspaceCloseListener; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.WorkspaceModificationListener; import software.coley.recaf.workspace.model.bundle.AndroidClassBundle; import software.coley.recaf.workspace.model.bundle.Bundle; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.ResourceAndroidClassListener; import software.coley.recaf.workspace.model.resource.ResourceJvmClassListener; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.IdentityHashMap; import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.Map; import java.util.Queue; import java.util.SequencedSet; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; import java.util.stream.Stream; /** * Represents class inheritance as a navigable graph. * * @author Matt Coley */ public class InheritanceGraph { /** Vertex used for classes that are not found in the workspace. */ private static final InheritanceVertex STUB = new InheritanceStubVertex(); private static final String OBJECT = "java/lang/Object"; private final Map> parentToChild; private final Map vertices; private final Set stubs = ConcurrentHashMap.newKeySet(); private final ListenerHost listener = new ListenerHost(); private final Workspace workspace; private final ClassPathNodeProvider workspaceNodeProvider; /** * Create an inheritance graph. * * @param workspace * Workspace to pull classes from. */ public InheritanceGraph(@Nonnull Workspace workspace) { this.workspace = workspace; this.workspaceNodeProvider = new ClassPathNodeProvider.Live(workspace); // Populate map lookups with the initial capacity of the number of classes in the workspace plus a buffer. int classesInWorkspace = workspace.allResourcesStream(false /* dont count internal resource classes */) .mapToInt(res -> res.classBundleStreamRecursive().mapToInt(Map::size).sum()) .sum() + 1; parentToChild = new ConcurrentHashMap<>(classesInWorkspace); vertices = new ConcurrentHashMap<>(classesInWorkspace); // Add listeners to primary resource so when classes update we keep our graph up to date. WorkspaceResource primaryResource = workspace.getPrimaryResource(); primaryResource.addResourceJvmClassListener(listener); primaryResource.addResourceAndroidClassListener(listener); workspace.addWorkspaceModificationListener(listener); // Populate downwards (parent --> child) lookup refreshChildLookup(); } /** * Registers our graph's listener for mapping updates. * * @param mappingListeners * Listener service to register within. */ public void installMappingListener(@Nonnull MappingListeners mappingListeners) { mappingListeners.addMappingApplicationListener(listener); } /** * Unregisters our graph's listener from mapping updates. * * @param mappingListeners * Listener service to unregister within. * @param purge * {@code true} to also clear the graph of all data, * {@code false} to just remove the listener and keep the graph data intact. */ public void uninstallMappingListener(@Nonnull MappingListeners mappingListeners, boolean purge) { // Remove the graph as a listener so that it can be feed by the garbage collector. mappingListeners.removeMappingApplicationListener(listener); // Notify the graph of closure. if (purge) listener.onWorkspaceClosed(workspace); } /** * Refresh parent-to-child lookup. */ private void refreshChildLookup() { // Clear parentToChild.clear(); // Repopulate ClassPathNodeProvider.Cached nodeProvider = ClassPathNodeProvider.cache(workspace); Set visited = Collections.newSetFromMap(new IdentityHashMap<>(nodeProvider.size() + 1024 /* leeway */)); workspace.forEachClass(false, cls -> populateParentToChildLookup(cls, visited, nodeProvider)); } /** * Populate a references from the given child class to the parent class. * * @param name * Child class name. * @param parentName * Parent class name. * @param provider * Node provider. */ private void populateParentToChildLookup(@Nonnull String name, @Nonnull String parentName, @Nonnull ClassPathNodeProvider provider) { parentToChild.computeIfAbsent(parentName, k -> ConcurrentHashMap.newKeySet()).add(name); // Clear any cached relationships in the vertex and the parent vertex. InheritanceVertex parentVertex = getVertex(parentName, provider); InheritanceVertex childVertex = getVertex(name, provider); if (parentVertex != null) parentVertex.clearCachedVertices(); if (childVertex != null) childVertex.clearCachedVertices(); } /** * Populate a references from the given child class to the parent class. * * @param name * Child class name. * @param parentName * Parent class name. */ private void populateParentToChildLookup(@Nonnull String name, @Nonnull String parentName) { populateParentToChildLookup(name, parentName, workspaceNodeProvider); } /** * Populate all references from the given child class to its parents. * * @param info * Child class. */ private void populateParentToChildLookup(@Nonnull ClassInfo info) { populateParentToChildLookup(info, Collections.newSetFromMap(new IdentityHashMap<>()), workspaceNodeProvider); } /** * Populate all references from the given child class to its parents. * * @param info * Child class. * @param visited * Classes already visited in population. * @param provider * Node provider. */ private void populateParentToChildLookup(@Nonnull ClassInfo info, @Nonnull Set visited, @Nonnull ClassPathNodeProvider provider) { // Since we have observed this class to exist, we will remove the "stub" placeholder for this name. stubs.remove(info.getName()); // Skip if already visited if (!visited.add(info)) return; // Skip module classes if (info.hasModuleModifier()) return; // Add direct parent String name = info.getName(); InheritanceVertex vertex = getVertex(name, provider); if (vertex != null) vertex.clearCachedVertices(); String superName = info.getSuperName(); if (superName != null) { populateParentToChildLookup(name, superName, provider); // Visit parent InheritanceVertex superVertex = getVertex(superName, provider); if (superVertex != null && !superVertex.isJavaLangObject() && !superVertex.isLoop()) populateParentToChildLookup(superVertex.getValue(), visited, provider); } // Add direct interfaces for (String itf : info.getInterfaces()) { populateParentToChildLookup(name, itf, provider); // Visit interfaces InheritanceVertex interfaceVertex = getVertex(itf, provider); if (interfaceVertex != null) populateParentToChildLookup(interfaceVertex.getValue(), visited, provider); } } /** * Populate all references from the given child class to its parents. * * @param info * Child class. * @param visited * Classes already visited in population. */ private void populateParentToChildLookup(@Nonnull ClassInfo info, @Nonnull Set visited) { populateParentToChildLookup(info, visited, workspaceNodeProvider); } /** * Remove all references from the given child class to its parents. * * @param info * Child class. */ private void removeParentToChildLookup(@Nonnull ClassInfo info) { String superName = info.getSuperName(); if (superName != null) removeParentToChildLookup(info.getName(), superName); for (String itf : info.getInterfaces()) removeParentToChildLookup(info.getName(), itf); } /** * Remove a references from the given child class to the parent class. * * @param name * Child class name. * @param parentName * Parent class name. */ private void removeParentToChildLookup(@Nonnull String name, @Nonnull String parentName) { Set children = parentToChild.get(parentName); if (children != null) children.remove(name); // Clear any cached relationships in the vertex and the parent vertex. InheritanceVertex parentVertex = getVertex(parentName); InheritanceVertex childVertex = getVertex(name); if (parentVertex != null) parentVertex.clearCachedVertices(); if (childVertex != null) childVertex.clearCachedVertices(); } /** * Removes the given class from the graph. * * @param cls * Class that was removed. */ private void removeClass(@Nonnull ClassInfo cls) { removeParentToChildLookup(cls); String name = cls.getName(); vertices.remove(name); } /** * @param parent * Parent to find children of. * * @return Direct extensions/implementations of the given parent. */ @Nonnull private Set getDirectChildren(@Nonnull String parent) { return parentToChild.getOrDefault(parent, Collections.emptySet()); } /** * @param name * Class name. * @param provider * Node provider. * * @return Vertex in graph of class. {@code null} if no such class was found in the inputs. */ @Nullable private InheritanceVertex getVertex(@Nonnull String name, @Nonnull ClassPathNodeProvider provider) { InheritanceVertex vertex = vertices.get(name); if (vertex == null && !stubs.contains(name)) { // Vertex does not exist and was not marked as a stub. // We want to look up the vertex for the given class and figure out if its valid or needs to be stubbed. InheritanceVertex provided = createVertex(name, provider); if (provided == STUB || provided == null) { // Provider yielded either a stub OR no result. Discard it. stubs.add(name); } else { // Provider yielded a valid vertex. Update the return value and record it in the map. vertices.put(name, provided); vertex = provided; } } return vertex; } /** * @param name * Class name. * * @return Vertex in graph of class. {@code null} if no such class was found in the inputs. */ @Nullable public InheritanceVertex getVertex(@Nonnull String name) { return getVertex(name, workspaceNodeProvider); } /** * @param name * Class name. * @param includeObject * {@code true} to include {@link Object} as a vertex. * * @return Complete inheritance family of the class. */ @Nonnull public Set getVertexFamily(@Nonnull String name, boolean includeObject) { InheritanceVertex vertex = getVertex(name); if (vertex == null) return Collections.emptySet(); if (vertex.isModule()) return Collections.singleton(vertex); return vertex.getFamily(includeObject); } /** * Given {@code List.class.isAssignableFrom(ArrayList.class)} the {@code first} parameter would be * {@code java/util/List} and the {@code second} parameter would be {@code java/util/ArrayList}. * * @param first * Assumed super-class or interface type. * @param second * Assumed child class which extends the super-class or implements the interface type. * * @return {@code true} when {@code first.isAssignableFrom(second)}. */ public boolean isAssignableFrom(@Nonnull String first, @Nonnull String second) { // Any Object can be assigned from T. if (OBJECT.equals(first)) return true; // Any T can be assigned from T. if (first.equals(second)) return true; // Any non-Object T cannot be assigned from Object. if (second.equals(OBJECT)) return false; // Lookup vertex for the child type, and see if any parent contains the supposed super/interface type. InheritanceVertex secondVertex = getVertex(second); if (secondVertex != null && secondVertex.hasParent(first)) return true; // Lookup vertex for the parent type, and see if any child contains the supposed type. InheritanceVertex firstVertex = getVertex(first); return firstVertex != null && firstVertex.hasChild(second); } /** * @param first * First class name. * @param second * Second class name. * * @return Common parent of the classes. */ @Nonnull public String getCommon(@Nonnull String first, @Nonnull String second) { // Easy base cases if (OBJECT.equals(first) || OBJECT.equals(second)) return OBJECT; if (first.equals(second)) return first; // Try with the first name InheritanceVertex vertex = getVertex(first); if (vertex != null) return getCommon(vertex, first, second); // Try again but with the other name vertex = getVertex(second); if (vertex != null) return getCommon(vertex, second, first); // Neither is resolvable return OBJECT; } /** * @param firstVertex * Vertex of the {@code first} name. * @param first * First class name. * @param second * Second class name. * * @return Common parent of the classes. */ @Nonnull private String getCommon(@Nonnull InheritanceVertex firstVertex, @Nonnull String first, @Nonnull String second) { // Full upwards hierarchy for the first SequencedSet firstParents = firstVertex.allParents() .map(InheritanceVertex::getParentAndCurrentNames) .flatMap(Collection::stream) .collect(Collectors.toCollection(LinkedHashSet::new)); firstParents.add(first); // Ensure 'Object' is last firstParents.remove(OBJECT); firstParents.add(OBJECT); // Base case if (firstParents.contains(second)) return second; // Iterate over second's parents via breadth-first-search Queue queue = new LinkedList<>(); queue.add(second); do { // Item to fetch parents of String next = queue.poll(); if (next == null || next.equals(OBJECT)) continue; InheritanceVertex nextVertex = getVertex(next); if (nextVertex == null) continue; for (String parent : nextVertex.getParents().stream() .map(InheritanceVertex::getParentAndCurrentNames) .flatMap(Collection::stream) .toList()) { if (!parent.equals(OBJECT)) { // Parent in the set of visited classes? Then its valid. if (firstParents.contains(parent)) return parent; // Queue up the parent queue.add(parent); } } } while (!queue.isEmpty()); // Fallback option return OBJECT; } /** * Check if the method is a library method. If the class is not found in the workspace, we assume it is a library method. * * @param name * Declaring class name. * @param methodName * Method name. * @param methodDesc * Method descriptor. * * @return {@code true} if the method is a library method, {@code false} otherwise. */ public boolean isLibraryMethod(@Nonnull String name, @Nonnull String methodName, @Nonnull String methodDesc) { InheritanceVertex vertex = getVertex(name); if (vertex == null) return true; // Not in the workspace, so we assume it is a library method. return vertex.isLibraryMethod(methodName, methodDesc); } /** * When {@link #STUB} is the return of this method, the class was not found. *
* When {@code null} is the return of this method, the class name is illegal. * * @param name * Internal class name. * @param provider * Node provider. * * @return Vertex of class. */ @Nullable private InheritanceVertex createVertex(@Nullable String name, @Nonnull ClassPathNodeProvider provider) { // Edge case handling for 'java/lang/Object' doing a parent lookup. // There is no parent, do not use STUB. if (name == null) return null; // Edge case handling for arrays. There is no object typing of arrays. if (name.isEmpty() || name.charAt(0) == '[') return null; // Find class in workspace, if not found yield stub. ClassPathNode result = provider.getNode(name); if (result == null) return STUB; // Map class to vertex. ResourcePathNode resourcePath = result.getPathOfType(WorkspaceResource.class); boolean isPrimary = resourcePath != null && resourcePath.isPrimaryOrEmbeddedInPrimary(); ClassInfo info = result.getValue(); return new InheritanceVertex(info, this::getVertex, this::getDirectChildren, isPrimary); } private void onUpdateClassImpl(@Nonnull ClassInfo oldValue, @Nonnull ClassInfo newValue) { String name = oldValue.getName(); if (!newValue.getName().equals(name)) throw new IllegalStateException("onUpdateClass should not permit a class name change"); // Update hierarchy now that super-name changed if (oldValue.getSuperName() != null && newValue.getSuperName() != null) { if (!oldValue.getSuperName().equals(newValue.getSuperName())) { removeParentToChildLookup(name, oldValue.getSuperName()); populateParentToChildLookup(name, newValue.getSuperName()); } } // Same deal, but for interfaces Set interfaces = new HashSet<>(oldValue.getInterfaces()); interfaces.addAll(newValue.getInterfaces()); for (String itf : interfaces) { boolean oldHas = oldValue.getInterfaces().contains(itf); boolean newHas = newValue.getInterfaces().contains(itf); if (oldHas && !newHas) { removeParentToChildLookup(name, itf); } else if (!oldHas && newHas) { populateParentToChildLookup(name, itf); } } // Update vertex wrapped class-info InheritanceVertex vertex = getVertex(name); if (vertex != null) vertex.setValue(newValue); } private class ListenerHost implements WorkspaceModificationListener, WorkspaceCloseListener, ResourceJvmClassListener, ResourceAndroidClassListener, MappingApplicationListener { @Override public void onNewClass(@Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo cls) { populateParentToChildLookup(cls); } @Override public void onNewClass(@Nonnull WorkspaceResource resource, @Nonnull AndroidClassBundle bundle, @Nonnull AndroidClassInfo cls) { populateParentToChildLookup(cls); } @Override public void onUpdateClass(@Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo oldCls, @Nonnull JvmClassInfo newCls) { onUpdateClassImpl(oldCls, newCls); } @Override public void onUpdateClass(@Nonnull WorkspaceResource resource, @Nonnull AndroidClassBundle bundle, @Nonnull AndroidClassInfo oldCls, @Nonnull AndroidClassInfo newCls) { onUpdateClassImpl(oldCls, newCls); } @Override public void onRemoveClass(@Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo cls) { removeClass(cls); } @Override public void onRemoveClass(@Nonnull WorkspaceResource resource, @Nonnull AndroidClassBundle bundle, @Nonnull AndroidClassInfo cls) { removeClass(cls); } @Override public void onAddLibrary(@Nonnull Workspace workspace, @Nonnull WorkspaceResource library) { Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); library.jvmClassBundleStreamRecursive() .flatMap(Bundle::stream) .forEach(c -> populateParentToChildLookup(c, visited)); library.androidClassBundleStreamRecursive() .flatMap(Bundle::stream) .forEach(c -> populateParentToChildLookup(c, visited)); refreshChildLookup(); } @Override public void onRemoveLibrary(@Nonnull Workspace workspace, @Nonnull WorkspaceResource library) { library.jvmClassBundleStreamRecursive() .flatMap(Bundle::stream) .forEach(InheritanceGraph.this::removeClass); library.androidClassBundleStreamRecursive() .flatMap(Bundle::stream) .forEach(InheritanceGraph.this::removeClass); refreshChildLookup(); } @Override public void onWorkspaceClosed(@Nonnull Workspace workspace) { parentToChild.clear(); vertices.clear(); stubs.clear(); } @Override public void onPreApply(@Nonnull Workspace workspace, @Nonnull MappingResults mappingResults) { // no-op } @Override public void onPostApply(@Nonnull Workspace workspace, @Nonnull MappingResults mappingResults) { // Must apply to the graph's associated workspace. if (InheritanceGraph.this.workspace != workspace) return; // Remove vertices and lookups of items that no longer exist. mappingResults.getPreMappingPaths().forEach((name, path) -> { // If we see a 'stub' from the vertex creator, we know it is no longer // in the workspace and should be removed from our cache. InheritanceVertex vertex = createVertex(name, workspaceNodeProvider); if (vertex == STUB) { vertices.remove(name); parentToChild.remove(name); } }); // While applying mappings, the graph does not perfectly refresh, so we need to clear out some state // so that when the graph is used again the correct information will be fetched. Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); mappingResults.getPostMappingPaths().forEach((name, path) -> { // Stub information for classes we know exist in the workspace should be removed. stubs.remove(name); // Refresh the parent-->children mapping. parentToChild.remove(name); ClassInfo postClass = path.getValue(); populateParentToChildLookup(postClass, visited); }); } } private static class InheritanceStubVertex extends InheritanceVertex { private InheritanceStubVertex() { super(new StubClassInfo("java/lang/Object").asJvmClass(), in -> null, in -> null, false); } @Override public boolean hasField(@Nonnull String name, @Nonnull String desc) { return false; } @Override public boolean hasMethod(@Nonnull String name, @Nonnull String desc) { return false; } @Override public boolean isJavaLangObject() { return false; } @Override public boolean isParentOf(@Nonnull InheritanceVertex vertex) { return false; } @Override public boolean isChildOf(@Nonnull InheritanceVertex vertex) { return false; } @Override public boolean isIndirectFamilyMember(@Nonnull InheritanceVertex vertex) { return false; } @Override public boolean isIndirectFamilyMember(@Nonnull Set family, @Nonnull InheritanceVertex vertex) { return false; } @Nonnull @Override public Set getFamily(boolean includeObject) { return Collections.emptySet(); } @Nonnull @Override public Set getAllParents() { return Collections.emptySet(); } @Nonnull @Override public Stream allParents() { return Stream.empty(); } @Nonnull @Override public Set getParents() { return Collections.emptySet(); } @Nonnull @Override public Set getAllChildren() { return Collections.emptySet(); } @Nonnull @Override public Set getChildren() { return Collections.emptySet(); } @Nonnull @Override public Set getAllDirectVertices() { return Collections.emptySet(); } @Nonnull @Override public String getName() { return "$$STUB$$"; } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/inheritance/InheritanceGraphService.java ================================================ package software.coley.recaf.services.inheritance; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.cdi.EagerInitialization; import software.coley.recaf.services.Service; import software.coley.recaf.services.mapping.MappingListeners; import software.coley.recaf.services.workspace.WorkspaceCloseListener; import software.coley.recaf.services.workspace.WorkspaceManager; import software.coley.recaf.workspace.model.Workspace; import java.util.Objects; /** * Service offering the creation of {@link InheritanceGraph inheritance graphs} for workspaces. * * @author Matt Coley * @see InheritanceGraph */ @EagerInitialization @ApplicationScoped public class InheritanceGraphService implements Service { public static final String SERVICE_ID = "graph-inheritance"; private final InheritanceGraphServiceConfig config; private final MappingListeners mappingListeners; private final WorkspaceManager workspaceManager; private volatile InheritanceGraph currentWorkspaceGraph; @Inject public InheritanceGraphService(@Nonnull WorkspaceManager workspaceManager, @Nonnull MappingListeners mappingListeners, @Nonnull InheritanceGraphServiceConfig config) { this.workspaceManager = workspaceManager; this.mappingListeners = mappingListeners; this.config = config; ListenerHost host = new ListenerHost(); workspaceManager.addWorkspaceCloseListener(host); } /** * Gets an existing graph if present for the workspace, * or makes a new one if there is no associated graph for the workspace. * * @param workspace * Workspace to pull classes from. * * @return Inheritance graph model for the given workspace. */ @Nonnull public InheritanceGraph getOrCreateInheritanceGraph(@Nonnull Workspace workspace) { return workspaceManager.getCurrent() == workspace ? Objects.requireNonNull(getCurrentWorkspaceInheritanceGraph(), "Failed to get current workspace graph") : newInheritanceGraph(workspace); } /** * @param workspace * Workspace to pull classes from. * * @return New inheritance graph model for the given workspace. */ @Nonnull public InheritanceGraph newInheritanceGraph(@Nonnull Workspace workspace) { return new InheritanceGraph(workspace); } /** * @return Inheritance graph model for the {@link WorkspaceManager#getCurrent() current workspace} * or {@code null} if no workspace is currently open. */ @Nullable public InheritanceGraph getCurrentWorkspaceInheritanceGraph() { if (!workspaceManager.hasCurrentWorkspace()) return null; // Building graphs can be expensive for large workspaces, so to prevent races we will double-check. if (currentWorkspaceGraph == null) { synchronized (this) { if (currentWorkspaceGraph == null) { InheritanceGraph graph = newInheritanceGraph(workspaceManager.getCurrent()); graph.installMappingListener(mappingListeners); currentWorkspaceGraph = graph; } } } return currentWorkspaceGraph; } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public InheritanceGraphServiceConfig getServiceConfig() { return config; } private class ListenerHost implements WorkspaceCloseListener { @Override public void onWorkspaceClosed(@Nonnull Workspace workspace) { if (currentWorkspaceGraph != null) { currentWorkspaceGraph.uninstallMappingListener(mappingListeners, true); currentWorkspaceGraph = null; } } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/inheritance/InheritanceGraphServiceConfig.java ================================================ package software.coley.recaf.services.inheritance; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link InheritanceGraphService} * * @author Matt Coley */ @ApplicationScoped public class InheritanceGraphServiceConfig extends BasicConfigContainer implements ServiceConfig { @Inject public InheritanceGraphServiceConfig() { super(ConfigGroups.SERVICE_ANALYSIS, InheritanceGraphService.SERVICE_ID + CONFIG_SUFFIX); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/inheritance/InheritanceVertex.java ================================================ package software.coley.recaf.services.inheritance; import jakarta.annotation.Nonnull; import software.coley.collections.Lists; import software.coley.collections.Sets; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.util.Streams; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.Set; import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.Stream; /** * Graph element for a class inheritance hierarchy. * * @author Matt Coley */ public class InheritanceVertex { private final Function lookup; private final Function> childrenLookup; private final boolean isPrimary; private volatile Set parents; private volatile Set children; private ClassInfo value; /** * @param value * The wrapped value. * @param lookup * Class vertex lookup. * @param childrenLookup * Class child lookup. * @param isPrimary * Flag for if the class belongs to a workspaces primary resource. */ public InheritanceVertex(@Nonnull ClassInfo value, @Nonnull Function lookup, @Nonnull Function> childrenLookup, boolean isPrimary) { this.value = value; this.lookup = lookup; this.childrenLookup = childrenLookup; this.isPrimary = isPrimary; } /** * @param name * Field name. * @param desc * Field descriptor. * * @return If the field exists in the current vertex. */ public boolean hasField(@Nonnull String name, @Nonnull String desc) { for (FieldMember fn : value.getFields()) if (fn.getName().equals(name) && fn.getDescriptor().equals(desc)) return true; return false; } /** * @param name * Field name. * @param desc * Field descriptor. * * @return If the field exists in the current vertex or in any parent vertex. */ public boolean hasFieldInSelfOrParents(@Nonnull String name, @Nonnull String desc) { if (hasField(name, desc)) return true; return allParents() .filter(v -> v != this) .anyMatch(parent -> parent.hasFieldInSelfOrParents(name, desc)); } /** * @param name * Field name. * @param desc * Field descriptor. * * @return If the field exists in the current vertex or in any child vertex. */ public boolean hasFieldInSelfOrChildren(@Nonnull String name, @Nonnull String desc) { if (hasField(name, desc)) return true; return allChildren() .filter(v -> v != this) .anyMatch(parent -> parent.hasFieldInSelfOrChildren(name, desc)); } /** * @param name * Method name. * @param desc * Method descriptor. * * @return If the method exists in the current vertex. */ public boolean hasMethod(@Nonnull String name, @Nonnull String desc) { for (MethodMember mn : value.getMethods()) if (mn.getName().equals(name) && mn.getDescriptor().equals(desc)) return true; return false; } /** * @param name * Method name. * @param desc * Method descriptor. * * @return If the method exists in the current vertex or in any parent vertex. */ public boolean hasMethodInSelfOrParents(@Nonnull String name, @Nonnull String desc) { if (hasMethod(name, desc)) return true; return allParents() .filter(v -> v != this) .anyMatch(parent -> parent.hasMethodInSelfOrParents(name, desc)); } /** * @param name * Method name. * @param desc * Method descriptor. * * @return If the method exists in the current vertex or in any child vertex. */ public boolean hasMethodInSelfOrChildren(@Nonnull String name, @Nonnull String desc) { if (hasMethod(name, desc)) return true; return allChildren() .filter(v -> v != this) .anyMatch(parent -> parent.hasMethodInSelfOrChildren(name, desc)); } /** * @return {@code true} if the class represented by this vertex is a library class. * This means a class that does not belong to the primary {@link WorkspaceResource} * of a {@link Workspace}. */ public boolean isLibraryVertex() { return !isPrimary; } /** * @return {@code true} when the current vertex represents {@link Object}. */ public boolean isJavaLangObject() { return getName().equals("java/lang/Object"); } /** * @return {@code true} when a parent of this vertex, is this vertex. */ public boolean isLoop() { // Our vertex model silently drops cycles to prevent infinite loops, so what we // do instead is check if any of the vertices in the graph for this class extend // or implement the current vertex's class. String name = getName(); Predicate extendsName = v -> { ClassInfo cls = v.getValue(); return name.equals(cls.getSuperName()) || cls.getInterfaces().contains(name); }; return extendsName.test(this) || allParents().anyMatch(extendsName); } /** * @return {@code true} when the current vertex represents a {@code module-info}. */ public boolean isModule() { return getValue().hasModuleModifier() && getValue().getSuperName() == null; } /** * @param name * Method name. * @param desc * Method descriptor. * * @return {@code true} if method is an extension of an outside class's methods and thus should not be renamed. * {@code false} if the method is safe to rename. */ public boolean isLibraryMethod(@Nonnull String name, @Nonnull String desc) { // Check against this definition if (!isPrimary && hasMethod(name, desc)) return true; // Check parents. // If we extend a class with a library definition then it should be considered a library method. for (InheritanceVertex parent : getParents()) if (parent.isLibraryMethod(name, desc)) return true; // No library definition found, so its safe to rename. return false; } /** * @param vertex * Supposed child vertex. * * @return {@code true} if the vertex is of a child type to this vertex's {@link #getName() type}. */ public boolean isParentOf(@Nonnull InheritanceVertex vertex) { return vertex.getAllParents().contains(this); } /** * @param vertex * Supposed parent vertex. * * @return {@code true} if the vertex is of a parent type to this vertex's {@link #getName() type}. */ public boolean isChildOf(@Nonnull InheritanceVertex vertex) { return getAllParents().contains(vertex); } /** * @param vertex * Supposed vertex that belongs in the family. * * @return {@code true} if the vertex is a family member, but is not a child or parent of the current vertex. */ public boolean isIndirectFamilyMember(@Nonnull InheritanceVertex vertex) { return isIndirectFamilyMember(getFamily(true), vertex); } /** * @param family * Family to check in. * @param vertex * Supposed vertex that belongs in the family. * * @return {@code true} if the vertex is a family member, but is not a child or parent of the current vertex. */ public boolean isIndirectFamilyMember(@Nonnull Set family, @Nonnull InheritanceVertex vertex) { return this != vertex && family.contains(vertex) && !isChildOf(vertex) && !isParentOf(vertex); } /** * @param name * Name of parent type. * * @return {@code true} when this vertex has the given parent. */ public boolean hasParent(@Nonnull String name) { // This first check serves multiple purposes. // - The name comparison on the wrapped class's parent/interfaces is faster // than walking the graph to find the same names in wrapped vertices // - This will cover cases where the given parent is not in the workspace // but is a direct parent of a class that is in the workspace ClassInfo cls = getValue(); if (name.equals(cls.getSuperName()) || cls.getInterfaces().contains(name)) return true; // Check all parents for a matching name, or the same check as above but for the parent. return allParents().anyMatch(parent -> { if (name.equals(parent.getName())) return true; ClassInfo parentCls = parent.getValue(); return name.equals(parentCls.getSuperName()) || parentCls.getInterfaces().contains(name); }); } /** * @param name * Name of child type. * * @return {@code true} when this vertex has the given child. */ public boolean hasChild(@Nonnull String name) { for (InheritanceVertex child : getAllChildren()) if (name.equals(child.getName())) return true; return false; } /** * @param includeObject * {@code true} to include {@link Object} as a vertex. * * @return The entire class hierarchy. */ @Nonnull public Set getFamily(boolean includeObject) { Set vertices = new LinkedHashSet<>(); visitFamily(vertices); if (!includeObject) vertices.removeIf(InheritanceVertex::isJavaLangObject); return vertices; } private void visitFamily(@Nonnull Set vertices) { if (isModule()) return; if (vertices.add(this) && !isJavaLangObject()) for (InheritanceVertex vertex : getAllDirectVertices()) vertex.visitFamily(vertices); } /** * @return All classes this extends or implements. */ @Nonnull public Set getAllParents() { return allParents().collect(Collectors.toCollection(LinkedHashSet::new)); } /** * @return All classes this extends or implements. */ @Nonnull public Stream allParents() { // Skip 1 to skip ourselves (which we use as the seed vertex) return Streams.recurseWithoutCycles(this, InheritanceVertex::getParents) .skip(1); } /** * @return Classes this directly extends or implements. */ @Nonnull public Set getParents() { Set parents = this.parents; if (parents == null) { synchronized (this) { if (isModule()) { parents = Collections.emptySet(); this.parents = parents; return parents; } parents = this.parents; if (parents == null) { String name = getName(); parents = new LinkedHashSet<>(); String superName = value.getSuperName(); if (superName != null && !name.equals(superName)) { InheritanceVertex parentVertex = lookup.apply(superName); if (parentVertex != null) parents.add(parentVertex); } for (String itf : value.getInterfaces()) { InheritanceVertex itfVertex = lookup.apply(itf); if (itfVertex != null && !name.equals(itf)) parents.add(itfVertex); } this.parents = parents; } } } return parents; } /** * @return All classes extending or implementing this type. */ @Nonnull public Set getAllChildren() { return allChildren().collect(Collectors.toCollection(LinkedHashSet::new)); } /** * @return Stream of all classes extending or implementing this type. */ @Nonnull public Stream allChildren() { // Skip 1 to skip ourselves (which we use as the seed vertex) return Streams.recurseWithoutCycles(this, InheritanceVertex::getChildren) .skip(1); } /** * @return Classes that extend or implement this class. */ @Nonnull public Set getChildren() { Set children = this.children; if (children == null) { synchronized (this) { if (isModule()) { children = Collections.emptySet(); this.children = children; return children; } children = this.children; if (children == null) { String name = getName(); children = childrenLookup.apply(value.getName()) .stream() .filter(childName -> !name.equals(childName)) .map(lookup) .filter(Objects::nonNull) .collect(Collectors.toCollection(LinkedHashSet::new)); this.children = children; } } } return children; } /** * @return All direct parents and child vertices. */ @Nonnull public Set getAllDirectVertices() { return Sets.combine(getParents(), getChildren()); } /** * Clears cached {@link #getParents()} and {@link #getChildren()} values. */ public void clearCachedVertices() { synchronized (this) { parents = null; children = null; } } /** * @return {@link #getValue() wrapped class's} name */ @Nonnull public String getName() { return value.getName(); } /** * @return List of the {@link #getValue() wrapped class's} super class name, and any implemented interfaces. */ @Nonnull public List getParentNames() { return value.getSuperName() != null ? Lists.add(value.getInterfaces(), value.getSuperName()) : value.getInterfaces(); } /** * @return Combined list of {@link #getName()} and {@link #getParentNames()}. */ @Nonnull public List getParentAndCurrentNames() { return Lists.add(getParentNames(), getName()); } /** * @return Wrapped class info. */ @Nonnull public ClassInfo getValue() { return value; } /** * @param value * New wrapped class info. */ public void setValue(@Nonnull ClassInfo value) { this.value = value; clearCachedVertices(); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; InheritanceVertex vertex = (InheritanceVertex) o; return Objects.equals(getName(), vertex.getName()); } @Override public int hashCode() { return getName().hashCode(); } @Override public String toString() { return getName(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/json/GsonProvider.java ================================================ package software.coley.recaf.services.json; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.InstanceCreator; import com.google.gson.JsonDeserializer; import com.google.gson.JsonSerializer; import com.google.gson.TypeAdapter; import com.google.gson.TypeAdapterFactory; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.services.Service; import java.lang.reflect.Type; import java.util.function.Consumer; /** * Manages a common {@link Gson} instance for consistent handling across Recaf. * * @author Matt Coley * @author Justus Garbe */ @ApplicationScoped public class GsonProvider implements Service { public static final String SERVICE_ID = "gson-provider"; private final GsonProviderConfig config; private GsonBuilder builder; @Inject public GsonProvider(@Nonnull GsonProviderConfig config) { this.config = config; } /** * @return A gson instance from the current builder parameters. */ @Nonnull public Gson getGson() { GsonBuilder copy = getBuilderCopy(); // Apply config to a copy of the builder. // We do this at this step because you cannot unset pretty-printing from the builder. // Thus, we'll add it as post-processing when the user requests a gson instance. if (config.getPrettyPrint().getValue()) copy.setPrettyPrinting(); return copy.create(); } /** * Register a type adapter factory for application-wide (de)serialization support * for supported types from the factory. * * @param factory * Adapter factory to register. * * @see GsonBuilder#registerTypeAdapterFactory(TypeAdapterFactory) */ public void addTypeAdapterFactory(@Nonnull TypeAdapterFactory factory) { updateBuilder(builder -> builder.registerTypeAdapterFactory(factory)); } /** * Register a type adapter for application-wide serialization support. * * @param type * Type definition for the type adapter being registered. * @param adapter * Adapter implementation for the given type. * @param * Type to adapt. * * @see GsonBuilder#registerTypeAdapter(Type, Object) */ public void addTypeAdapter(@Nonnull Class type, @Nonnull TypeAdapter adapter) { register(type, adapter); } /** * Register an instance creator for application-wide support. * * @param type * Type definition for the instance creator being registered. * @param creator * Instance creator implementation for the given type. * @param * Type to adapt. * * @see GsonBuilder#registerTypeAdapter(Type, Object) */ public void addTypeInstanceCreator(@Nonnull Class type, @Nonnull InstanceCreator creator) { register(type, creator); } /** * Register a type deserializer for application-wide support. * * @param type * Type definition for the type deserializer being registered. * @param deserializer * Deserializer implementation for the given type. * @param * Type to adapt. * * @see GsonBuilder#registerTypeAdapter(Type, Object) */ public void addTypeDeserializer(@Nonnull Class type, @Nonnull JsonDeserializer deserializer) { register(type, deserializer); } /** * Register a type serializer for application-wide support. * * @param type * Type definition for the type serializer being registered. * This type must be the exact-intended type. When a subtype is serialized this will not be used. * @param serializer * Serializer implementation for the given type. * @param * Type to adapt. * * @see GsonBuilder#registerTypeAdapter(Type, Object) */ public void addTypeSerializer(@Nonnull Class type, @Nonnull JsonSerializer serializer) { register(type, serializer); } /** * Register a type adapter (Loose terminology, multiple types are supported) * for application-wide serialization support. * * @param type * Type definition for the type adapter being registered. * @param adapter * Adapter implementation for the given type. Must be a {@link TypeAdapter}, * {@link InstanceCreator}, {@link JsonSerializer},or {@link JsonDeserializer}. * * @see GsonBuilder#registerTypeAdapter(Type, Object) */ private void register(@Nonnull Class type, @Nonnull Object adapter) { updateBuilder(builder -> builder.registerTypeAdapter(type, adapter)); } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public GsonProviderConfig getServiceConfig() { return config; } /** * @return Copy of the current {@link #builder}. */ @Nonnull private GsonBuilder getBuilderCopy() { GsonBuilder local = builder; // No builder set up yet, so it'd be the default. if (local == null) return new GsonBuilder(); // Create a copy of the builder instance. // It has the same setup as the managed builder instance. return local.create().newBuilder(); } /** * Update the {@link #builder} instance. * * @param consumer * Consumer to adapt the builder config with. */ private void updateBuilder(@Nullable Consumer consumer) { GsonBuilder newBuilder = getBuilderCopy(); if (consumer != null) consumer.accept(newBuilder); builder = newBuilder; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/json/GsonProviderConfig.java ================================================ package software.coley.recaf.services.json; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.observables.ObservableBoolean; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.BasicConfigValue; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link GsonProvider}. * * @author Matt Coley */ @ApplicationScoped public class GsonProviderConfig extends BasicConfigContainer implements ServiceConfig { private final ObservableBoolean prettyPrint = new ObservableBoolean(true); @Inject public GsonProviderConfig() { super(ConfigGroups.SERVICE_IO, GsonProvider.SERVICE_ID + CONFIG_SUFFIX); addValue(new BasicConfigValue<>("pretty-print", boolean.class, prettyPrint)); } /** * @return {@code true} to enable pretty printing with {@link GsonProvider}. */ @Nonnull public ObservableBoolean getPrettyPrint() { return prettyPrint; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/BasicMappingsRemapper.java ================================================ package software.coley.recaf.services.mapping; import jakarta.annotation.Nonnull; import org.objectweb.asm.Handle; import org.objectweb.asm.commons.Remapper; import software.coley.recaf.RecafConstants; /** * {@link Remapper} implementation that delegates to a provided {@link Mappings} and supports local variable renaming. * * @author Matt Coley */ public class BasicMappingsRemapper extends Remapper { protected final Mappings mappings; private boolean modified; /** * @param mappings * Mappings to pull from. */ public BasicMappingsRemapper(@Nonnull Mappings mappings) { super(RecafConstants.getAsmVersion()); this.mappings = mappings; } @Override public String map(String internalName) { String mapped = mappings.getMappedClassName(internalName); if (mapped != null) { markModified(); return mapped; } return super.map(internalName); } @Override public String mapType(String internalName) { // Type can be null (object supertype, or module-info) if (internalName == null) return null; // Check for array type if (internalName.charAt(0) == '[') return mapDesc(internalName); // Standard internal name return map(internalName); } @Override public String mapFieldName(String owner, String name, String descriptor) { String mapped = mappings.getMappedFieldName(owner, name, descriptor); if (mapped != null) { markModified(); return mapped; } return super.mapFieldName(owner, name, descriptor); } @Override public String mapMethodName(String owner, String name, String descriptor) { String mapped = mappings.getMappedMethodName(owner, name, descriptor); if (mapped != null) { markModified(); return mapped; } return super.mapMethodName(owner, name, descriptor); } @Override public String mapMethodDesc(String methodDescriptor) { int lastTypeEndOffset = methodDescriptor.indexOf(';'); if (lastTypeEndOffset == -1) { // No object typees to map return methodDescriptor; } int lastTypeStartOffset = 0; StringBuilder builder = new StringBuilder(methodDescriptor.length()); int tail; do { int bookkeep = lastTypeStartOffset; lastTypeStartOffset = methodDescriptor.indexOf('L', lastTypeStartOffset); // Append leftover parts on the left side builder.append(methodDescriptor, bookkeep, lastTypeStartOffset); String type = methodDescriptor.substring(lastTypeStartOffset + 1, lastTypeEndOffset); String mapped = mapType(type); builder.append('L').append(mapped).append(';'); // Skip L_TYPE_; lastTypeStartOffset += type.length() + 2; tail = lastTypeStartOffset; } while ((lastTypeEndOffset = methodDescriptor.indexOf(';', lastTypeEndOffset + 1)) != -1); // Append remaining characters (tail onwards) builder.append(methodDescriptor, tail, methodDescriptor.length()); return builder.toString(); } @Override public String mapDesc(String descriptor) { if (descriptor == null || descriptor.isEmpty()) { return descriptor; } if (descriptor.charAt(0) == '(') { return mapMethodDesc(descriptor); } if (descriptor.charAt(descriptor.length() - 1) != ';') { return descriptor; } int dimensions = 0; while (descriptor.charAt(dimensions) == '[') { dimensions++; } StringBuilder builder = new StringBuilder(descriptor.length()); int bookkeep = dimensions; while (dimensions-- != 0) { builder.append('['); } builder.append('L'); builder.append(map(descriptor.substring(bookkeep + 1, descriptor.length() - 1))); builder.append(';'); return builder.toString(); } @Override public String mapRecordComponentName(String owner, String name, String descriptor) { return mapMethodName(owner, name, descriptor); } @Override public String mapPackageName(String name) { // Used only by module attributes return name; } @Override public String mapModuleName(String name) { // Used only by module attributes return name; } @Override public String mapAnnotationAttributeName(String descriptor, String name) { // Used by annotation visitor return name; } @Override public String mapInvokeDynamicMethodName(String name, String descriptor, Handle bootstrapMethodHandle, Object... bootstrapMethodArguments) { return name; } /** * @param className * Internal name of the class defining the method the variable resides in. * @param methodName * Name of the method. * @param methodDesc * Descriptor of the method. * @param name * Name of the variable. * @param desc * Descriptor of the variable. * @param index * Index of the variable. * * @return Mapped name of the variable, or the existing name if no mapping exists. */ public String mapVariableName(String className, String methodName, String methodDesc, String name, String desc, int index) { String mapped = mappings.getMappedVariableName(className, methodName, methodDesc, name, desc, index); if (mapped != null) { markModified(); return mapped; } // Use existing variable name. return name; } protected void markModified() { modified = true; } /** * @return {@code true} when any mapping has been found and used. */ public boolean hasMappingBeenApplied() { return modified; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/IntermediateMappings.java ================================================ package software.coley.recaf.services.mapping; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.collections.Lists; import software.coley.recaf.services.mapping.data.ClassMapping; import software.coley.recaf.services.mapping.data.FieldMapping; import software.coley.recaf.services.mapping.data.MethodMapping; import software.coley.recaf.services.mapping.data.VariableMapping; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; /** * Collection of object representations of mappings. * Useful as an intermediate between multiple types of {@link Mappings}. * * @author Matt Coley */ public class IntermediateMappings implements Mappings { protected final Map classes = new ConcurrentHashMap<>(); protected final Map> fields = new ConcurrentHashMap<>(); protected final Map> methods = new ConcurrentHashMap<>(); protected final Map> variables = new ConcurrentHashMap<>(); /** * Copies all values from the given mappings into this instance. * * @param other * Another intermediate mappings instance. */ public void putAll(@Nonnull IntermediateMappings other) { classes.putAll(other.classes); other.fields.forEach((className, otherFields) -> fields.merge(className, otherFields, Lists::combine)); other.methods.forEach((className, otherMethods) -> methods.merge(className, otherMethods, Lists::combine)); other.variables.forEach((className, otherVariables) -> variables.merge(className, otherVariables, Lists::combine)); } /** * @param oldName * Pre-mapping name. * @param newName * Post-mapping name. */ public void addClass(@Nonnull String oldName, @Nonnull String newName) { if (Objects.equals(oldName, newName)) return; // Skip identity mappings classes.put(oldName, new ClassMapping(oldName, newName)); } /** * @param ownerName * Name of class defining the field. * @param desc * Descriptor type of the field. * @param oldName * Pre-mapping field name. * @param newName * Post-mapping field name. */ public void addField(@Nonnull String ownerName, @Nullable String desc, @Nonnull String oldName, @Nonnull String newName) { if (Objects.equals(oldName, newName)) return; // Skip identity mappings fields.computeIfAbsent(ownerName, n -> Collections.synchronizedList(new ArrayList<>())) .add(new FieldMapping(ownerName, oldName, desc, newName)); } /** * @param ownerName * Name of class defining the method. * @param desc * Descriptor type of the method. * @param oldName * Pre-mapping method name. * @param newName * Post-mapping method name. */ public void addMethod(@Nonnull String ownerName, @Nonnull String desc, @Nonnull String oldName, @Nonnull String newName) { if (Objects.equals(oldName, newName)) return; // Skip identity mappings methods.computeIfAbsent(ownerName, n -> Collections.synchronizedList(new ArrayList<>())) .add(new MethodMapping(ownerName, oldName, desc, newName)); } /** * @param ownerName * Name of class defining the method. * @param methodName * Pre-mapping method name. * @param methodDesc * Descriptor type of the method. * @param desc * Variable descriptor. * @param oldName * Variable old name. * @param index * Variable index. * @param newName * Post-mapping method name. */ public void addVariable(@Nonnull String ownerName, @Nonnull String methodName, @Nonnull String methodDesc, @Nullable String desc, @Nullable String oldName, int index, @Nonnull String newName) { if (Objects.equals(oldName, newName)) return; // Skip identity mappings String key = varKey(ownerName, methodName, methodDesc); variables.computeIfAbsent(key, n -> Collections.synchronizedList(new ArrayList<>())) .add(new VariableMapping(ownerName, methodName, methodDesc, desc, oldName, index, newName)); } /** * @return {@code true} when this mappings container has no entries. */ public boolean isEmpty() { return classes.isEmpty() && fields.isEmpty() && methods.isEmpty() && variables.isEmpty(); } /** * @return Names of classes with mappings. */ @Nonnull public Set getClassesWithMappings() { Set set = new TreeSet<>(); set.addAll(classes.keySet()); set.addAll(fields.keySet()); set.addAll(methods.keySet()); return set; } /** * @return Class mappings. */ @Nonnull public Map getClasses() { return classes; } /** * @return Field mappings by owner type. */ @Nonnull public Map> getFields() { return fields; } /** * @return Method mappings by owner type. */ @Nonnull public Map> getMethods() { return methods; } /** * @return Variable mappings by declaring method type. */ @Nonnull public Map> getVariables() { return variables; } /** * @param name * Pre-mapping name. * * @return Mapping instance of class. May be {@code null}. */ @Nullable public ClassMapping getClassMapping(@Nonnull String name) { return classes.get(name); } /** * @param name * Declaring class name. * * @return List of field mapping instances. */ @Nonnull public List getClassFieldMappings(@Nonnull String name) { return fields.getOrDefault(name, Collections.emptyList()); } /** * @param name * Declaring class name. * * @return List of method mapping instances. */ @Nonnull public List getClassMethodMappings(@Nonnull String name) { return methods.getOrDefault(name, Collections.emptyList()); } /** * @param ownerName * Declaring class name. * @param methodName * Declaring method name. * @param methodDesc * Declaring method descriptor. * * @return List of field mapping instances. */ @Nonnull public List getMethodVariableMappings(@Nonnull String ownerName, @Nonnull String methodName, @Nonnull String methodDesc) { return variables.getOrDefault(varKey(ownerName, methodName, methodDesc), Collections.emptyList()); } @Nullable @Override public String getMappedClassName(@Nonnull String internalName) { ClassMapping mapping = getClassMapping(internalName); if (mapping == null) return null; return mapping.getNewName(); } @Nullable @Override public String getMappedFieldName(@Nonnull String ownerName, @Nonnull String fieldName, @Nonnull String fieldDesc) { List fieldInClass = getClassFieldMappings(ownerName); for (FieldMapping field : fieldInClass) // Some mapping formats exclude descriptors (which sucks) so we bypass the desc check if that is the case. if ((field.getDesc() == null || Objects.equals(fieldDesc, field.getDesc())) && field.getOldName().equals(fieldName)) return field.getNewName(); return null; } @Nullable @Override public String getMappedMethodName(@Nonnull String ownerName, @Nonnull String methodName, @Nonnull String methodDesc) { List methodsInClass = getClassMethodMappings(ownerName); for (MethodMapping method : methodsInClass) if (methodDesc.equals(method.getDesc()) && method.getOldName().equals(methodName)) return method.getNewName(); return null; } @Nullable @Override public String getMappedVariableName(@Nonnull String className, @Nonnull String methodName, @Nonnull String methodDesc, @Nullable String name, @Nullable String desc, int index) { List variablesInMethod = getMethodVariableMappings(className, methodName, methodDesc); for (VariableMapping variable : variablesInMethod) { if (equalsOrNull(desc, variable.getDesc()) && equalsOrNull(name, variable.getOldName()) && indexEqualsOrOOB(index, variable.getIndex())) { return variable.getNewName(); } } return null; } @Nonnull @Override public IntermediateMappings exportIntermediate() { return this; } @Nonnull protected static String varKey(@Nonnull String ownerName, @Nonnull String methodName, @Nonnull String methodDesc) { return ownerName + "\t" + methodName + "\t" + methodDesc; } private static boolean indexEqualsOrOOB(int a, int b) { return a < 0 || b < 0 || a == b; } private static boolean equalsOrNull(@Nullable String a, @Nullable String b) { return a == null || b == null || a.equals(b); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/MappingApplicationListener.java ================================================ package software.coley.recaf.services.mapping; import jakarta.annotation.Nonnull; import software.coley.recaf.behavior.PrioritySortable; import software.coley.recaf.workspace.model.Workspace; /** * Used to intercept application state before and after {@link MappingResults#apply()}. * * @author Matt Coley * @see MappingResults Can be added to the constuctor to affect a single mapping job. * @see MappingListeners Can be added in order to affect all mapping jobs. */ public interface MappingApplicationListener extends PrioritySortable { /** * @param workspace * Workspace the mappings are applied to. * @param mappingResults * Mapping results to be applied. */ void onPreApply(@Nonnull Workspace workspace, @Nonnull MappingResults mappingResults); /** * @param workspace * Workspace the mappings are applied to. * @param mappingResults * Mapping results that were applied. */ void onPostApply(@Nonnull Workspace workspace, @Nonnull MappingResults mappingResults); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/MappingApplier.java ================================================ package software.coley.recaf.services.mapping; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.ClassWriter; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.properties.builtin.HasMappedReferenceProperty; import software.coley.recaf.info.properties.builtin.OriginalClassNameProperty; import software.coley.recaf.info.properties.builtin.RemapOriginTaskProperty; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.services.mapping.aggregate.AggregateMappingManager; import software.coley.recaf.services.workspace.WorkspaceManager; import software.coley.recaf.util.threading.ThreadPoolFactory; import software.coley.recaf.util.threading.ThreadUtil; import software.coley.recaf.util.visitors.IllegalSignatureRemovingVisitor; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.Collection; import java.util.concurrent.ExecutorService; import java.util.stream.Stream; /** * Applies mappings to workspaces and workspace resources, wrapping the results in a {@link MappingResults}. * To update the workspace with the mapping results, use {@link MappingResults#apply()}. * * @author Matt Coley * @see MappingResults */ public class MappingApplier { private static final ExecutorService applierThreadPool = ThreadPoolFactory.newFixedThreadPool(MappingApplierService.SERVICE_ID); private final InheritanceGraph inheritanceGraph; private final AggregateMappingManager aggregateMappingManager; private final MappingListeners listeners; private final Workspace workspace; /** * @param workspace * Workspace to apply mappings in. * @param inheritanceGraph * Inheritance graph for the given workspace. * @param listeners * Application mapping listeners * (If the target workspace is the {@link WorkspaceManager#getCurrent() current one}) * @param aggregateMappingManager * Aggregate mappings for tracking applications in the current workspace * (If the target workspace is the {@link WorkspaceManager#getCurrent() current one}) */ public MappingApplier(@Nonnull Workspace workspace, @Nonnull InheritanceGraph inheritanceGraph, @Nullable MappingListeners listeners, @Nullable AggregateMappingManager aggregateMappingManager) { this.inheritanceGraph = inheritanceGraph; this.aggregateMappingManager = aggregateMappingManager; this.listeners = listeners; this.workspace = workspace; } /** * Applies the mapping operation to the given classes. * * @param mappings * The mappings to apply. * @param resource * Resource containing the classes. * @param bundle * Bundle containing the classes. * @param classes * Classes to apply mappings to. * * @return Result wrapper detailing affected classes from the mapping operation. */ @Nonnull public MappingResults applyToClasses(@Nonnull Mappings mappings, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull Collection classes) { mappings = enrich(mappings); MappingApplicationListener listener = listeners == null ? null : listeners.createBundledMappingApplicationListener(); MappingResults results = new MappingResults(workspace, mappings, listener); if (aggregateMappingManager != null) results.withAggregateManager(aggregateMappingManager); // Apply mappings to the provided classes, collecting into the results model. Mappings finalMappings = mappings; ExecutorService service = ThreadUtil.phasingService(applierThreadPool); for (JvmClassInfo classInfo : classes) service.execute(() -> dumpIntoResults(results, workspace, resource, bundle, classInfo, finalMappings)); ThreadUtil.blockUntilComplete(service); // Yield results return results; } /** * Applies the mapping operation to the current workspace's primary resource. * * @param mappings * The mappings to apply. * * @return Result wrapper detailing affected classes from the mapping operation. */ @Nonnull public MappingResults applyToPrimaryResource(@Nonnull Mappings mappings) { mappings = enrich(mappings); MappingApplicationListener listener = listeners == null ? null : listeners.createBundledMappingApplicationListener(); MappingResults results = new MappingResults(workspace, mappings, listener); if (aggregateMappingManager != null) results.withAggregateManager(aggregateMappingManager); // Apply mappings to all classes in the primary resource, collecting into the results model. Mappings finalMappings = mappings; ExecutorService service = ThreadUtil.phasingService(applierThreadPool); WorkspaceResource resource = workspace.getPrimaryResource(); resource.jvmAllClassBundleStreamRecursive().forEach(bundle -> { bundle.forEach(classInfo -> { service.execute(() -> dumpIntoResults(results, workspace, resource, bundle, classInfo, finalMappings)); }); }); ThreadUtil.blockUntilComplete(service); // Yield results return results; } @Nonnull private Mappings enrich(@Nonnull Mappings mappings) { // Map intermediate mappings to the adapter so that we can pass in the inheritance graph for better coverage // of cases inherited field/method references. if (mappings instanceof IntermediateMappings intermediateMappings) { // Mapping formats that export to intermediate should mark whether they support // differentiation of field and variable types. boolean fieldDifferentiation = mappings.doesSupportFieldTypeDifferentiation(); boolean varDifferentiation = mappings.doesSupportVariableTypeDifferentiation(); MappingsAdapter adapter = new MappingsAdapter(fieldDifferentiation, varDifferentiation); adapter.importIntermediate(intermediateMappings); mappings = adapter; } // Check if mappings can be enriched with type look-ups if (mappings instanceof MappingsAdapter adapter) { // If we have "Dog extends Animal" and both define "jump" this lets "Dog.jump()" see "Animal.jump()" // allowing mappings that aren't complete for their type hierarchies to be filled in. adapter.enableHierarchyLookup(inheritanceGraph); } return mappings; } /** * Applies mappings locally and dumps them into the provided results collection. *

* To apply these mappings you need to call {@link MappingResults#apply()}. * * @param results * Results collection to insert into. * @param workspace * Containing workspace. * @param resource * Containing resource. * @param bundle * Containing bundle. * @param classInfo * The class to apply mappings to. * @param mappings * The mappings to apply. */ private static void dumpIntoResults(@Nonnull MappingResults results, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo classInfo, @Nonnull Mappings mappings) { String originalName = classInfo.getName(); // Apply renamer ClassReader reader = classInfo.getClassReader(); ClassWriter writer = new ClassWriter(reader, 0); WorkspaceClassRemapper remapVisitor = new WorkspaceClassRemapper(writer, workspace, mappings); ClassVisitor cv = new IllegalSignatureRemovingVisitor(remapVisitor); // Wrap because ASM crashes otherwise with obfuscated inputs. reader.accept(cv, classInfo.getClassReaderFlags()); // Update class if it has any modified references if (remapVisitor.hasMappingBeenApplied()) { JvmClassInfo updatedInfo = classInfo.toJvmClassBuilder() .adaptFrom(writer.toByteArray()) .build(); // Mark has referencing something mapped. HasMappedReferenceProperty.set(updatedInfo); // Set the result wrapper that caused this class to update. updatedInfo.setProperty(new RemapOriginTaskProperty(results)); // If the name changed, mark what the original was. // If this property was set before (A --> B, now B --> C) then we won't update it. if (!updatedInfo.getName().equals(originalName)) updatedInfo.setPropertyIfMissing(OriginalClassNameProperty.KEY, () -> new OriginalClassNameProperty(originalName)); // Add to the results collection. results.add(workspace, resource, bundle, classInfo, updatedInfo); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/MappingApplierConfig.java ================================================ package software.coley.recaf.services.mapping; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link MappingApplierService} * * @author Matt Coley */ @ApplicationScoped public class MappingApplierConfig extends BasicConfigContainer implements ServiceConfig { @Inject public MappingApplierConfig() { super(ConfigGroups.SERVICE_MAPPING, MappingApplierService.SERVICE_ID + CONFIG_SUFFIX); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/MappingApplierService.java ================================================ package software.coley.recaf.services.mapping; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.services.Service; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.services.inheritance.InheritanceGraphService; import software.coley.recaf.services.mapping.aggregate.AggregateMappingManager; import software.coley.recaf.services.workspace.WorkspaceManager; import software.coley.recaf.util.threading.ThreadPoolFactory; import software.coley.recaf.workspace.model.Workspace; import java.util.Objects; import java.util.concurrent.ExecutorService; /** * Service offering the creation of {@link MappingApplier mapping appliers} for workspaces. * * @author Matt Coley * @see MappingApplier */ @ApplicationScoped public class MappingApplierService implements Service { public static final String SERVICE_ID = "mapping-applier"; private static final ExecutorService applierThreadPool = ThreadPoolFactory.newFixedThreadPool(SERVICE_ID); private final InheritanceGraphService inheritanceGraphService; private final AggregateMappingManager aggregateMappingManager; private final MappingListeners listeners; private final WorkspaceManager workspaceManager; private final MappingApplierConfig config; @Inject public MappingApplierService(@Nonnull MappingApplierConfig config, @Nonnull InheritanceGraphService inheritanceGraphService, @Nonnull AggregateMappingManager aggregateMappingManager, @Nonnull MappingListeners listeners, @Nonnull WorkspaceManager workspaceManager) { this.inheritanceGraphService = inheritanceGraphService; this.aggregateMappingManager = aggregateMappingManager; this.listeners = listeners; this.workspaceManager = workspaceManager; this.config = config; } /** * @param workspace * Workspace to apply mappings in. * * @return Applier for the given workspace. */ @Nonnull public MappingApplier inWorkspace(@Nonnull Workspace workspace) { if (workspace == workspaceManager.getCurrent()) return Objects.requireNonNull(inCurrentWorkspace(), "Failed to access current workspace for mapping application"); return new MappingApplier(workspace, inheritanceGraphService.newInheritanceGraph(workspace), listeners, null); } /** * @return Applier for the current workspace, or {@code null} if no workspace is open. */ @Nullable public MappingApplier inCurrentWorkspace() { if (!workspaceManager.hasCurrentWorkspace()) return null; InheritanceGraph currentWorkspaceInheritanceGraph = inheritanceGraphService.getCurrentWorkspaceInheritanceGraph(); if (currentWorkspaceInheritanceGraph == null) return null; Workspace workspace = workspaceManager.getCurrent(); return new MappingApplier(workspace, currentWorkspaceInheritanceGraph, listeners, aggregateMappingManager); } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public MappingApplierConfig getServiceConfig() { return config; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/MappingListeners.java ================================================ package software.coley.recaf.services.mapping; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.slf4j.Logger; import software.coley.collections.Unchecked; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.behavior.PrioritySortable; import software.coley.recaf.services.Service; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.Collection; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; /** * Manages listeners for things like {@link MappingResults} applying operations. * * @author Matt Coley */ @ApplicationScoped public class MappingListeners implements Service { public static final String SERVICE_ID = "mapping-listeners"; private static final Logger logger = Logging.get(MappingListeners.class); private final List mappingApplicationListeners = new CopyOnWriteArrayList<>(); private final MappingListenersConfig config; @Inject public MappingListeners(@Nonnull MappingListenersConfig config) { this.config = config; } /** * Adds a listener which is passed to created {@link MappingResults} from * {@link MappingApplier#applyToPrimaryResource(Mappings)} and * {@link MappingApplier#applyToClasses(Mappings, WorkspaceResource, JvmClassBundle, Collection)}. *

* This allows you to listen to all mapping operations done via proper API usage, intercepting before they * execute the task, and after they complete the mapping task. * * @param listener * Listener to add. */ public synchronized void addMappingApplicationListener(@Nonnull MappingApplicationListener listener) { if (!mappingApplicationListeners.contains(listener)) PrioritySortable.add(mappingApplicationListeners, listener); } /** * @param listener * Listener to remove. * * @return {@code true} when item was removed. * {@code false} when item was not in the list to begin with. */ public synchronized boolean removeMappingApplicationListener(@Nonnull MappingApplicationListener listener) { return mappingApplicationListeners.remove(listener); } /** * @return Application listener encompassing all the current items in {@link #mappingApplicationListeners}, * or {@code null} if there are no listeners. */ @Nullable public MappingApplicationListener createBundledMappingApplicationListener() { final List listeners = mappingApplicationListeners; // Simple edge cases. if (listeners.isEmpty()) return null; else if (listeners.size() == 1) return listeners.getFirst(); // Bundle multiple listeners. return new MappingApplicationListener() { @Override public void onPreApply(@Nonnull Workspace workspace, @Nonnull MappingResults mappingResults) { Unchecked.checkedForEach(listeners, listener -> listener.onPreApply(workspace, mappingResults), (listener, t) -> logger.error("Exception thrown before applying mappings", t)); } @Override public void onPostApply(@Nonnull Workspace workspace, @Nonnull MappingResults mappingResults) { Unchecked.checkedForEach(listeners, listener -> listener.onPostApply(workspace, mappingResults), (listener, t) -> logger.error("Exception thrown after applying mappings", t)); } }; } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public MappingListenersConfig getServiceConfig() { return config; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/MappingListenersConfig.java ================================================ package software.coley.recaf.services.mapping; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link MappingListeners} * * @author Matt Coley */ @ApplicationScoped public class MappingListenersConfig extends BasicConfigContainer implements ServiceConfig { @Inject public MappingListenersConfig() { super(ConfigGroups.SERVICE_MAPPING, MappingListeners.SERVICE_ID + CONFIG_SUFFIX); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/MappingResults.java ================================================ package software.coley.recaf.services.mapping; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.slf4j.Logger; import software.coley.collections.tuple.Pair; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.path.BundlePathNode; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.path.PathNodes; import software.coley.recaf.services.mapping.aggregate.AggregateMappingManager; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.Bundle; import software.coley.recaf.workspace.model.bundle.ClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.HashMap; import java.util.Map; import java.util.SortedSet; import java.util.TreeSet; import java.util.stream.Stream; /** * Result wrapper for {@link MappingApplier} operations. * Can serve as a preview for mapping operations before updating the affected {@link Workspace}. *
* Use {@link #apply()} to apply the mappings to the {@link WorkspaceResource} targeted in the mapping operation. * * @author Matt Coley */ public class MappingResults { private static final Logger logger = Logging.get(MappingResults.class); private final Map mappedClasses = new HashMap<>(); private final Map mappedClassesReverse = new HashMap<>(); private final Map preMappingPaths = new HashMap<>(); private final Map postMappingPaths = new HashMap<>(); private final MappingApplicationListener applicationHandler; private final Workspace workspace; private final Mappings mappings; private AggregateMappingManager aggregateMappingManager; /** * @param workspace * The workspace the mappings are being applied to. * @param mappings * The mappings implementation used in the operation. * @param applicationHandler * Optional handler for intercepting post/pre mapping states. */ public MappingResults(@Nonnull Workspace workspace, @Nonnull Mappings mappings, @Nullable MappingApplicationListener applicationHandler) { this.workspace = workspace; this.mappings = mappings; this.applicationHandler = applicationHandler; } /** * @param aggregateMappingManager * Aggregate mapping manager to track mapping applications in. * * @return Self. */ @Nonnull public MappingResults withAggregateManager(@Nonnull AggregateMappingManager aggregateMappingManager) { this.aggregateMappingManager = aggregateMappingManager; return this; } /** * @param workspace * Workspace containing the class. * @param resource * Resource containing the class. * @param bundle * Bundle containing the class. * @param preMapping * The pre-mapped class. * @param postMapping * The post-mapped class. */ public void add(@Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull ClassBundle bundle, @Nonnull ClassInfo preMapping, @Nonnull ClassInfo postMapping) { String preMappingName = preMapping.getName(); String postMappingName = postMapping.getName(); BundlePathNode bundlePath = PathNodes.bundlePath(workspace, resource, bundle); ClassPathNode preMappingPath = bundlePath.child(preMapping.getPackageName()).child(preMapping); ClassPathNode postMappingPath = bundlePath.child(postMapping.getPackageName()).child(postMapping); synchronized (mappedClasses) { mappedClasses.put(preMappingName, postMappingName); } synchronized (mappedClassesReverse) { mappedClassesReverse.put(postMappingName, preMappingName); } synchronized (preMappingPaths) { preMappingPaths.put(preMappingName, preMappingPath); } synchronized (postMappingPaths) { postMappingPaths.put(postMappingName, postMappingPath); } } /** * Applies the mappings to the {@link Workspace} / {@link WorkspaceResource} from {@link MappingApplier}. */ @SuppressWarnings("unchecked") public void apply() { // Track changes in aggregate manager, if given. if (aggregateMappingManager != null) aggregateMappingManager.updateAggregateMappings(mappings); // Pass to handler to notify of application of mappings has started. if (applicationHandler != null) try { applicationHandler.onPreApply(workspace, this); } catch (Throwable t) { logger.error("Mapping application handler failed on pre-application", t); } // Record mapping application jobs into a sorted set. // We want to apply some changes before others. SortedSet applicationEntries = new TreeSet<>(); for (Map.Entry entry : mappedClasses.entrySet()) { String preMappedName = entry.getKey(); String postMappedName = entry.getValue(); ClassPathNode preMappedPath = preMappingPaths.get(preMappedName); ClassPathNode postMappedPath = postMappingPaths.get(postMappedName); if (preMappedPath != null && postMappedPath != null) { applicationEntries.add(new ApplicationEntry(preMappedPath, postMappedPath, () -> { ClassBundle bundle = (ClassBundle) postMappedPath.getValueOfType(Bundle.class); if (bundle == null) throw new IllegalStateException("Cannot apply mapping for '" + preMappedName + "', path missing bundle"); // Put mapped class into bundle ClassInfo postMappedClass = postMappedPath.getValue(); bundle.put(postMappedClass); // Remove old classes if they have been renamed and do not occur // in a set of newly applied names if (!preMappedName.equals(postMappedName)) bundle.remove(preMappedName); })); } } // Apply changes in sorted order. for (ApplicationEntry entry : applicationEntries) entry.applicationRunnable().run(); // Log in console how many classes got mapped. logger.info("Applied mapping to {} classes", preMappingPaths.size()); // Pass to handler again to notify of application of mappings has completed/ if (applicationHandler != null) try { applicationHandler.onPostApply(workspace, this); } catch (Throwable t) { logger.error("Mapping application handler failed on post-application", t); } } /** * @return The mappings implementation used in the operation. */ @Nonnull public Mappings getMappings() { return mappings; } /** * @param preMappedName * Pre-mapping name. * * @return {@code true} when the class was affected by the mapping operation. */ public boolean wasMapped(@Nonnull String preMappedName) { return mappedClasses.containsKey(preMappedName); } /** * @param postMappingName * Post-mapping name. * * @return Name of the class before the mapping operation. * May be {@code null} if the post-mapping name was not renamed during the mapping operation. */ @Nullable public String getPreMappingName(@Nonnull String postMappingName) { return mappedClassesReverse.get(postMappingName); } /** * @param preMappingName * Pre-mapping name. * * @return Post-mapped class info. * May be {@code null} if no the given pre-mapped name was not affected by the mapping operation. */ @Nullable public ClassInfo getPostMappingClass(@Nonnull String preMappingName) { ClassPathNode postMappingPath = getPostMappingPath(preMappingName); if (postMappingPath == null) return null; return postMappingPath.getValue(); } /** * @param preMappingName * Pre-mapping name. * * @return Path node of post-mapped class. * May be {@code null} if no the given pre-mapped name was not affected by the mapping operation. */ @Nullable public ClassPathNode getPostMappingPath(@Nonnull String preMappingName) { String postMappingName = mappedClasses.get(preMappingName); if (postMappingName == null) return null; return postMappingPaths.get(postMappingName); } /** * @param postMappingName * Post-mapping name. * * @return Pre-mapped class info. * May be {@code null} if no the given post-mapped name was not present in the mapping operation output. */ @Nullable public ClassInfo getPreMappingClass(@Nonnull String postMappingName) { ClassPathNode preMappingPath = getPreMappingPath(postMappingName); if (preMappingPath == null) return null; return preMappingPath.getValue(); } /** * @param postMappingName * Post-mapping name. * * @return Path node of pre-mapped class. * May be {@code null} if no the given post-mapped name was not present in the mapping operation output. */ @Nullable public ClassPathNode getPreMappingPath(@Nonnull String postMappingName) { String preMappedName = getPreMappingName(postMappingName); return preMappedName == null ? null : preMappingPaths.get(preMappedName); } /** * @return Stream of mapped path pairs. * The {@link Pair#getLeft()} holds the pre-mapped path. * The {@link Pair#getRight()} holds the post-mapped path, which may be {@code null} in some cases. */ @Nonnull public Stream> streamPreToPostMappingPaths() { return getPreMappingPaths().values().stream() .map(p -> new Pair<>(p, getPostMappingPath(p.getValue().getName()))); } /** * @return Mapping of affected classes, to their new names. * If a class was affected, but the name not changed, the key and value for that entry will be the same. */ @Nonnull public Map getMappedClasses() { return mappedClasses; } /** * @return Mapping of pre-mapped names to their path nodes. */ @Nonnull public Map getPreMappingPaths() { return preMappingPaths; } /** * @return Mapping of post-mapped names to their path nodes. */ @Nonnull public Map getPostMappingPaths() { return postMappingPaths; } /** * This class exists to facilitate sorting the order of which classes get updated in the workspace. * The preferred order is: *

    *
  1. Classes with NEW names
  2. *
  3. Classes with LOW complexity
  4. *
  5. Anything else
  6. *
* The reason for NEW classes being first is so that existing classes, when updated, can see the NEW classes * in the workspace. *
* Then LOW complexity classes are next. Statistically speaking it is typical for complex classes to rely on many * more less complex classes. Following the principle of making new types available first, we make changes to the * LOW complexity classes so that HIGH complexity classes can pull data from them. * * @param pre * Pre mapped path. * @param post * Post mapped path. * @param applicationRunnable * Runnable that applies the mapping to the associated workspace. */ private record ApplicationEntry(@Nonnull ClassPathNode pre, @Nonnull ClassPathNode post, @Nonnull Runnable applicationRunnable) implements Comparable { /** * @return {@code true} when pre-and-post mapping names are the same. * Indicates the class was not mapped, but some references within it to others have been. */ private boolean isNameIdentity() { return pre.getValue().getName().equals(post.getValue().getName()); } /** * @return Rough level of complexity of the class in terms of how many types it references. */ public int complexity() { ClassInfo classInfo = post.getValue(); if (classInfo.isJvmClass()) return classInfo.asJvmClass().getReferencedClasses().size(); return -1; } @Override public int compareTo(@Nonnull ApplicationEntry o) { boolean identity = isNameIdentity(); boolean identityOther = o.isNameIdentity(); // Entries with new names go first. if (identity && !identityOther) // Other class got renamed, we want it to go first. return 1; else if (!identity && identityOther) // We got renamed, we want to go first. return -1; // We want more complex classes to go last. int cmp = Integer.compare(complexity(), o.complexity()); if (cmp != 0) return cmp; // Always want a unique ordering, so as a last resort we will compare by name. return post().getValue().getName().compareTo(o.post().getValue().getName()); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/Mappings.java ================================================ package software.coley.recaf.services.mapping; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.services.mapping.format.MappingFileFormat; /** * Outline of intermediate mappings, allowing for clear retrieval regardless of internal storage of mappings. *
*

Relevant noteworthy points

* Incomplete mappings: When imported from a {@link MappingFileFormat} not all formats are made equal. * Some contain less information than others. See the note in {@link MappingFileFormat} for more information. *

* Member references pointing to child sub-types: References to class members can point to child sub-types of * the class that defines the member. You may need to check the owner's type hierarchy to see if the field or method * is actually defined by a parent class. * * @author Matt Coley */ public interface Mappings { /** * Some mapping formats do not include field types since name overloading is illegal at the source level of Java. * It's valid in the bytecode but the mapping omits this info since it isn't necessary information for mapping * that does not support name overloading. *

* This is mostly only relevant for usage of {@link MappingsAdapter} which * * @return {@code true} when field mappings include the type descriptor in their lookup information. */ default boolean doesSupportFieldTypeDifferentiation() { return true; } /** * Some mapping formats do not include variable types since name overloading is illegal at the source level of Java. * Variable names are not used by the JVM at all so their names can be anything at the bytecode level. So including * the type makes it easier to reverse mappings. * * @return {@code true} when variable mappings include the type descriptor in their lookup information. */ default boolean doesSupportVariableTypeDifferentiation() { return true; } /** * @param classInfo * Class to lookup. * * @return Mapped name of the class, or {@code null} if no mapping exists. */ @Nullable default String getMappedClassName(@Nonnull ClassInfo classInfo) { return getMappedClassName(classInfo.getName()); } /** * @param internalName * Original class's internal name. * * @return Mapped name of the class, or {@code null} if no mapping exists. */ @Nullable String getMappedClassName(@Nonnull String internalName); /** * @param owner * Class declaring the field.
* NOTE: References to class members can point to child sub-types of the class that defines the member. * You may need to check the owner's type hierarchy to see if the field is actually defined in a parent class. * @param field * Field to lookup. * * @return Mapped name of the field, or {@code null} if no mapping exists. */ @Nullable default String getMappedFieldName(@Nonnull ClassInfo owner, @Nonnull FieldMember field) { return getMappedFieldName(owner.getName(), field.getName(), field.getDescriptor()); } /** * @param ownerName * Internal name of the class defining the field.
* NOTE: References to class members can point to child sub-types of the class that defines the member. * You may need to check the owner's type hierarchy to see if the field is actually defined in a parent class. * @param fieldName * Name of the field. * @param fieldDesc * Descriptor of the field. * * @return Mapped name of the field, or {@code null} if no mapping exists. */ @Nullable String getMappedFieldName(@Nonnull String ownerName, @Nonnull String fieldName, @Nonnull String fieldDesc); /** * @param owner * Class declaring the method.
* NOTE: References to class members can point to child sub-types of the class that defines the member. * You may need to check the owner's type hierarchy to see if the field is actually defined in a parent class. * @param method * Method to lookup. * * @return Mapped name of the method, or {@code null} if no mapping exists. */ @Nullable default String getMappedMethodName(@Nonnull ClassInfo owner, @Nonnull MethodMember method) { return getMappedMethodName(owner.getName(), method.getName(), method.getDescriptor()); } /** * @param ownerName * Internal name of the class defining the method.
* NOTE: References to class members can point to child sub-types of the class that defines the member. * You may need to check the owner's type hierarchy to see if the field is actually defined in a parent class. * @param methodName * Name of the method. * @param methodDesc * Descriptor of the method. * * @return Mapped name of the method, or {@code null} if no mapping exists. */ @Nullable String getMappedMethodName(@Nonnull String ownerName, @Nonnull String methodName, @Nonnull String methodDesc); /** * @param className * Internal name of the class defining the method the variable resides in. * @param methodName * Name of the method. * @param methodDesc * Descriptor of the method. * @param name * Name of the variable. * @param desc * Descriptor of the variable. * @param index * Index of the variable. * * @return Mapped name of the variable, or {@code null} if no mapping exists. */ @Nullable String getMappedVariableName(@Nonnull String className, @Nonnull String methodName, @Nonnull String methodDesc, @Nullable String name, @Nullable String desc, int index); /** * Generally this is implemented under the assumption that {@link Mappings} is used to model data explicitly. * For instance, if we have a workspace with a class {@code Person} using this we can see the {@code Person} * in the resulting {@link IntermediateMappings#getClasses()}. *
* However, when {@link Mappings} is used to pattern-match and replace (Like replacing a prefix/package * in a class name) then there is no way to model this since we don't know all possible matches beforehand. * In such cases, we should avoid using this method. * But for API consistency an empty {@link IntermediateMappings} should be returned. * * @return Object representation of mappings. */ @Nonnull IntermediateMappings exportIntermediate(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/MappingsAdapter.java ================================================ package software.coley.recaf.services.mapping; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.services.inheritance.InheritanceVertex; import software.coley.recaf.services.mapping.data.*; import software.coley.recaf.workspace.model.Workspace; import java.util.HashMap; import java.util.Iterator; import java.util.Map; import java.util.TreeMap; import java.util.function.Function; /** * A {@link Mappings} implementation with a number of additional operations to support usage beyond basic mapping info storage. * Enhancements *

    *
  1. Import mapping entries from a {@link IntermediateMappings} instance.
  2. *
  3. Enhance field/method lookups with inheritance info from {@link InheritanceGraph}, see {@link #enableHierarchyLookup(InheritanceGraph)}.
  4. *
  5. Enhance inner/outer class mapping edge cases via {@link #enableClassLookup(Workspace)}.
  6. *
  7. Adapt keys in cases where fields/vars do not have type info associated with them (for formats that suck).
  8. *
* * @author Matt Coley */ public class MappingsAdapter implements Mappings { private final Map mappings = new HashMap<>(); private final boolean supportFieldTypeDifferentiation; private final boolean supportVariableTypeDifferentiation; private InheritanceGraph inheritanceGraph; private Workspace workspace; /** * @param supportFieldTypeDifferentiation * {@code true} if the mapping format implementation includes type descriptors in field mappings. * @param supportVariableTypeDifferentiation * {@code true} if the mapping format implementation includes type descriptors in variable mappings. */ public MappingsAdapter(boolean supportFieldTypeDifferentiation, boolean supportVariableTypeDifferentiation) { this.supportFieldTypeDifferentiation = supportFieldTypeDifferentiation; this.supportVariableTypeDifferentiation = supportVariableTypeDifferentiation; } /** * Adds all the entries in the given mappings to the current mappings. * * @param mappings * Intermediate mappings to add to the current mappings. */ public void importIntermediate(@Nonnull IntermediateMappings mappings) { for (String className : mappings.getClassesWithMappings()) { ClassMapping classMapping = mappings.getClassMapping(className); if (classMapping != null) { String oldClassName = classMapping.getOldName(); String newClassName = classMapping.getNewName(); if (!oldClassName.equals(newClassName)) addClass(oldClassName, newClassName); } for (FieldMapping fieldMapping : mappings.getClassFieldMappings(className)) { String oldName = fieldMapping.getOldName(); String newName = fieldMapping.getNewName(); if (!oldName.equals(newName)) { if (doesSupportFieldTypeDifferentiation()) { addField(fieldMapping.getOwnerName(), oldName, fieldMapping.getDesc(), newName); } else { addField(fieldMapping.getOwnerName(), oldName, newName); } } } for (MethodMapping methodMapping : mappings.getClassMethodMappings(className)) { String oldMethodName = methodMapping.getOldName(); String oldMethodDesc = methodMapping.getDesc(); String newMethodName = methodMapping.getNewName(); if (!oldMethodName.equals(newMethodName)) addMethod(methodMapping.getOwnerName(), oldMethodName, oldMethodDesc, newMethodName); for (VariableMapping variableMapping : mappings.getMethodVariableMappings(className, oldMethodName, oldMethodDesc)) { addVariable(className, oldMethodName, oldMethodDesc, variableMapping.getOldName(), variableMapping.getDesc(), variableMapping.getIndex(), variableMapping.getNewName()); } } } } @Nullable @Override public String getMappedClassName(@Nonnull String internalName) { String mapped = mappings.get(getClassKey(internalName)); if (mapped == null) { if (workspace != null) { // Pull the actual outer class name from the class-info in the workspace if available. ClassPathNode classPath = workspace.findClass(internalName); if (classPath != null) { ClassInfo info = classPath.getValue(); String name = info.getName(); String outerName = info.getOuterClassName(); if (outerName != null && outerName.length() < name.length()) { String inner = name.substring(outerName.length() + 1); String outerMapped = getMappedClassName(outerName); if (outerMapped != null) { mapped = outerMapped + "$" + inner; } } } } else if (isInner(internalName)) { // We don't have a workspace, so the best we can do is assume standard 'Outer$Inner' conventions. int split = internalName.lastIndexOf("$"); String inner = internalName.substring(split + 1); String outer = internalName.substring(0, split); String outerMapped = getMappedClassName(outer); if (outerMapped != null) { mapped = outerMapped + "$" + inner; } } } return mapped; } @Nullable @Override public String getMappedFieldName(@Nonnull String ownerName, @Nonnull String fieldName, @Nonnull String fieldDesc) { MappingKey key = getFieldKey(ownerName, fieldName, fieldDesc); String mapped = mappings.get(key); if (mapped == null && inheritanceGraph != null) { mapped = findInParent(ownerName, parent -> getFieldKey(parent, fieldName, fieldDesc)); } return mapped; } @Nullable @Override public String getMappedMethodName(@Nonnull String ownerName, @Nonnull String methodName, @Nonnull String methodDesc) { MappingKey key = getMethodKey(ownerName, methodName, methodDesc); String mapped = mappings.get(key); if (mapped == null && inheritanceGraph != null) { mapped = findInParent(ownerName, parent -> getMethodKey(parent, methodName, methodDesc)); } return mapped; } @Nullable @Override public String getMappedVariableName(@Nonnull String className, @Nonnull String methodName, @Nonnull String methodDesc, @Nullable String name, @Nullable String desc, int index) { MappingKey key = getVariableKey(className, methodName, methodDesc, name, desc, index); return mappings.get(key); } @Nonnull @Override public IntermediateMappings exportIntermediate() { IntermediateMappings intermediate = new IntermediateMappings(); for (Map.Entry entry : new TreeMap<>(mappings).entrySet()) { MappingKey key = entry.getKey(); String newName = entry.getValue(); if (key instanceof ClassMappingKey ck) { intermediate.addClass(ck.getName(), newName); } else if (key instanceof MethodMappingKey mk) { intermediate.addMethod(mk.getOwner(), mk.getDesc(), mk.getName(), newName); } else if (key instanceof FieldMappingKey fk) { String oldOwner = fk.getOwner(); String oldName = fk.getName(); String oldDesc = fk.getDesc(); intermediate.addField(oldOwner, oldDesc, oldName, newName); } } return intermediate; } @Override public boolean doesSupportFieldTypeDifferentiation() { return supportFieldTypeDifferentiation; } @Override public boolean doesSupportVariableTypeDifferentiation() { return supportVariableTypeDifferentiation; } /** * @param owner * Internal name of the class "defining" the member. * (Location in reference may not be where the member is actually defined, hence this lookup) * @param lookup * Function that takes in the parent names of the given member owner class, * and converts it to a member lookup key via {@link #getFieldKey(String, String, String)} or * {@link #getMethodKey(String, String, String)}. * * @return The first mapping match in a parent class found by the lookup function. */ private String findInParent(String owner, Function lookup) { // TODO: Diamond class hierarchy doesn't work with this. // We need to visit the whole family tree. InheritanceVertex vertex = inheritanceGraph.getVertex(owner); if (vertex == null) return null; Iterator iterator = vertex.allParents().iterator(); while (iterator.hasNext()) { vertex = iterator.next(); MappingKey key = lookup.apply(vertex.getName()); String result = mappings.get(key); if (result != null) { return result; } } return null; } /** * @param internalName * Some class name. * * @return {@code true} when the class by the given name is an inner class of another class. */ private boolean isInner(String internalName) { int splitIndex = internalName.lastIndexOf("$"); // Ensure there is text before and after the split return splitIndex > 1 && splitIndex < internalName.length() - 1; } /** * Allows the mappings to use class inheritance graphs to check for key matches where the field or method is * defined in a parent class.
* An example of this would be if you were looking to map {@link Map#size()} but the reference in the bytecode * was to {@link TreeMap#size()}. If you only have the {@link Map} entry you need the type hierarchy to find that * {@link TreeMap} is a child of {@link Map} and thus should "inherit" the mapping of {@link Map#size()}. * * @param inheritanceGraph * Inheritance graph to use. */ public void enableHierarchyLookup(@Nonnull InheritanceGraph inheritanceGraph) { this.inheritanceGraph = inheritanceGraph; } /** * Allows the mappings to use data from classes in the workspace to better handle some edge cases. * For example, inner class name handling in {@link #getMappedClassName(String)}. * * @param workspace * Workspace to pull from. */ public void enableClassLookup(@Nonnull Workspace workspace) { this.workspace = workspace; } /** * Add mapping for class name. * * @param originalName * Original name. * @param renamedName * New name. */ public void addClass(@Nonnull String originalName, @Nonnull String renamedName) { mappings.put(getClassKey(originalName), renamedName); } /** * Add mapping for field name.
* Used when {@link #doesSupportFieldTypeDifferentiation()} is {@code true}. * * @param owner * Class name defining the field. * @param originalName * Original name of the field. * @param desc * Type descriptor of the field. * @param renamedName * New name of the method. */ public void addField(@Nonnull String owner, @Nonnull String originalName, @Nonnull String desc, @Nonnull String renamedName) { if (doesSupportFieldTypeDifferentiation()) { mappings.put(getFieldKey(owner, originalName, desc), renamedName); } else { throw new IllegalStateException("The current mapping implementation does not support " + "field type differentiation"); } } /** * Add mapping for field name.
* Used when {@link #doesSupportFieldTypeDifferentiation()} is {@code false}. * * @param owner * Class name defining the field. * @param originalName * Original name of the field. * @param renamedName * New name of the field. */ public void addField(@Nonnull String owner, @Nonnull String originalName, @Nonnull String renamedName) { if (doesSupportFieldTypeDifferentiation()) { throw new IllegalStateException("The current mapping implementation requires " + "specifying field descriptors"); } else { mappings.put(getFieldKey(owner, originalName, null), renamedName); } } /** * Add mapping for method name. * * @param owner * Class name defining the method. * @param originalName * Original name of the method. * @param desc * Type descriptor of the method. * @param renamedName * New name of the method. */ public void addMethod(@Nonnull String owner, @Nonnull String originalName, @Nonnull String desc, @Nonnull String renamedName) { mappings.put(getMethodKey(owner, originalName, desc), renamedName); } /** * Add mapping for variable name. * * @param className * Class name defining the method. * @param methodName * Method name. * @param methodDesc * Method descriptor. * @param originalName * Variable original name. * @param desc * Variable descriptor. * @param index * Variable index. * @param renamedName * New name of the variable. */ public void addVariable(@Nonnull String className, @Nonnull String methodName, @Nonnull String methodDesc, @Nonnull String originalName, @Nullable String desc, int index, @Nonnull String renamedName) { MappingKey key = getVariableKey(className, methodName, methodDesc, originalName, desc, index); mappings.put(key, renamedName); } /** * @param name * Class name. * * @return Key format for class. */ @Nonnull protected MappingKey getClassKey(@Nonnull String name) { return new ClassMappingKey(name); } /** * @param ownerName * Class defining the field. * @param fieldName * Name of field. * @param fieldDesc * Type descriptor of field. * * @return Key format for field. */ @Nonnull protected MappingKey getFieldKey(@Nonnull String ownerName, @Nonnull String fieldName, @Nullable String fieldDesc) { return new FieldMappingKey(ownerName, fieldName, supportFieldTypeDifferentiation ? fieldDesc : null); } /** * @param ownerName * Class defining the method. * @param methodName * Name of method. * @param methodDesc * Type descriptor of method. * * @return Key format for method. */ @Nonnull protected MappingKey getMethodKey(@Nonnull String ownerName, @Nonnull String methodName, @Nonnull String methodDesc) { return new MethodMappingKey(ownerName, methodName, methodDesc); } /** * @param className * Class defining the method. * @param methodName * Name of method. * @param methodDesc * Type descriptor of method. * @param name * Name of variable. * @param desc * Type descriptor of variable. * @param index * Local variable table index. * * @return Key format for variable. */ @Nonnull protected MappingKey getVariableKey(@Nonnull String className, @Nonnull String methodName, @Nonnull String methodDesc, @Nullable String name, @Nullable String desc, int index) { return new VariableMappingKey(className, methodName, methodDesc, name, desc); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/UniqueKeyMappings.java ================================================ package software.coley.recaf.services.mapping; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import java.util.Collection; import java.util.HashMap; import java.util.Map; /** * A simple {@link Mappings} implementation that assumes all classes, fields, and methods are uniquely identified. * Mapping outputs are keyed to these unique identifiers, rather than a full {@code name + desc} pair. *

* This mappings implementation can be useful to pass to {@link MappingApplier} for some scenarios like * Minecraft mappings, where each class, field, and method are uniquely identified. Minecraft's Forge, Fabric-Yarn and * Mod-Coder-Pack all have "intermediate" names that follow this pattern. *

* Example use case: *

{@code
 * MappingApplier applier = ...;
 * EnigmaMappings enigma = ...;
 *
 * // Read minecraft yarn mappings from a directory
 * IntermediateMappings mappings = enigma.parse(Paths.get("fabric/yarn/1.20.1"));
 *
 * // Convert to unique-keyed mappings
 * UniqueKeyMappings unique = new UniqueKeyMappings(mappings);
 *
 * // Use the unique-keyed mappings with a mapping-applier on a fabric mod
 * applier.applyToPrimaryResource(unique);
 * }
* * @author Matt Coley */ public class UniqueKeyMappings implements Mappings { private final Map mappings = new HashMap<>(); private final IntermediateMappings backing; /** * @param backing * Backing intermediate mappings to adapt into unique keyed mappings. */ public UniqueKeyMappings(@Nonnull IntermediateMappings backing) { // We need to explicitly keep the input mappings for 'exportIntermediate()' support. this.backing = backing; populateLookup(); } /** * Copies all values from the backing mappings into this instance. */ private void populateLookup() { backing.classes.values() .forEach(mapping -> mappings.put(mapping.getOldName(), mapping.getNewName())); backing.fields.values().stream() .flatMap(Collection::stream) .forEach(mapping -> mappings.put(mapping.getOldName(), mapping.getNewName())); backing.methods.values().stream() .flatMap(Collection::stream) .forEach(mapping -> mappings.put(mapping.getOldName(), mapping.getNewName())); backing.variables.values().stream() .flatMap(Collection::stream) .forEach(mapping -> { if (mapping.getOldName() != null) mappings.put(mapping.getOldName(), mapping.getNewName()); else mappings.put(mapping.getMethodName() + "." + mapping.getIndex(), mapping.getNewName()); }); } @Nullable @Override public String getMappedClassName(@Nonnull String internalName) { return mappings.get(internalName); } @Nullable @Override public String getMappedFieldName(@Nonnull String ownerName, @Nonnull String fieldName, @Nonnull String fieldDesc) { return mappings.get(fieldName); } @Nullable @Override public String getMappedMethodName(@Nonnull String ownerName, @Nonnull String methodName, @Nonnull String methodDesc) { return mappings.get(methodName); } @Nullable @Override public String getMappedVariableName(@Nonnull String className, @Nonnull String methodName, @Nonnull String methodDesc, @Nullable String name, @Nullable String desc, int index) { String mapped = null; if (name != null) mapped = mappings.get(name); if (mapped == null) mapped = mappings.get(methodName + "." + index); return mapped; } @Nonnull @Override public IntermediateMappings exportIntermediate() { // Yield the backing intermediate mappings. return backing; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/WorkspaceBackedRemapper.java ================================================ package software.coley.recaf.services.mapping; import jakarta.annotation.Nonnull; import org.objectweb.asm.Handle; import org.objectweb.asm.Type; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.util.Handles; import software.coley.recaf.workspace.model.Workspace; /** * Enhanced {@link BasicMappingsRemapper} for cases where additional information * needs to be pulled from a {@link Workspace}. * * @author Matt Coley */ public class WorkspaceBackedRemapper extends BasicMappingsRemapper { private final Workspace workspace; /** * @param workspace * Workspace to pull class info from when additional context is needed. * @param mappings * Mappings wrapper to pull values from. */ public WorkspaceBackedRemapper(@Nonnull Workspace workspace, @Nonnull Mappings mappings) { super(mappings); this.workspace = workspace; } @Override public String mapAnnotationAttributeName(String descriptor, String name) { String annotationName = Type.getType(descriptor).getInternalName(); ClassPathNode classPath = workspace.findClass(annotationName); // Not found, probably not intended to be renamed. if (classPath == null) return name; // Get the declaration and, if found, treat as normal method mapping. ClassInfo info = classPath.getValue(); MethodMember attributeMethod = info.getMethods().stream() .filter(method -> method.getName().equals(name)) .findFirst().orElse(null); // Not found, shouldn't generally happen. if (attributeMethod == null) return name; // Use the method mapping from the annotation class's declared methods. return mapMethodName(annotationName, name, attributeMethod.getDescriptor()); } @Override @SuppressWarnings("deprecation") public String mapInvokeDynamicMethodName(String name, String descriptor) { // Deprecated in ASM 9.9 - Keeping this here for a bit to ensure nobody accidentally calls it. throw new IllegalStateException("Enhanced 'mapInvokeDynamicMethodName(...)' usage required, missing handle arg"); } /** * @param name * The name of the method. * @param descriptor * The descriptor of the method. * @param bsm * The bootstrap method handle. * @param bsmArguments * The arguments to the bsm. * * @return New name of the method. */ @Nonnull @Override public String mapInvokeDynamicMethodName(@Nonnull String name, @Nonnull String descriptor, @Nonnull Handle bsm, @Nonnull Object[] bsmArguments) { if (bsm.equals(Handles.META_FACTORY)) { // Get the interface from the descriptor return type. String interfaceOwner = Type.getReturnType(descriptor).getInternalName(); // Get the method descriptor from the implementation handle (2nd arg value) if (bsmArguments[1] instanceof Handle implementationHandle) { String interfaceMethodDesc = implementationHandle.getDesc(); return mapMethodName(interfaceOwner, name, interfaceMethodDesc); } } // Not a known method handle type, so we do not know how to bootstrapMethodHandle renaming it. return name; } /** * @param className * Internal name of the class defining the method the variable resides in. * @param methodName * Name of the method. * @param methodDesc * Descriptor of the method. * @param name * Name of the variable. * @param desc * Descriptor of the variable. * @param index * Index of the variable. * * @return Mapped name of the variable, or the existing name if no mapping exists. */ @Nonnull public String mapVariableName(@Nonnull String className, @Nonnull String methodName, @Nonnull String methodDesc, String name, String desc, int index) { String mapped = mappings.getMappedVariableName(className, methodName, methodDesc, name, desc, index); if (mapped != null) { markModified(); return mapped; } // Use existing variable name. return name; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/WorkspaceClassRemapper.java ================================================ package software.coley.recaf.services.mapping; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.Handle; import org.objectweb.asm.Label; import org.objectweb.asm.MethodVisitor; import org.objectweb.asm.commons.ClassRemapper; import org.objectweb.asm.commons.MethodRemapper; import org.objectweb.asm.commons.Remapper; import software.coley.recaf.RecafConstants; import software.coley.recaf.workspace.model.Workspace; /** * A {@link ClassRemapper} implementation that delegates to a provided {@link Mappings} via {@link WorkspaceBackedRemapper}. *
* When applied to a class you can check if any modifications have been made via {@link #hasMappingBeenApplied()}. * * @author Matt Coley */ public class WorkspaceClassRemapper extends ClassRemapper { private final WorkspaceBackedRemapper workspaceRemapper; /** * @param cv * Class to visit and rename mapped items of. * @param workspace * Workspace to pull class info from when additional context is needed. * @param mappings * Mappings to apply. */ public WorkspaceClassRemapper(@Nullable ClassVisitor cv, @Nonnull Workspace workspace, @Nonnull Mappings mappings) { super(RecafConstants.getAsmVersion(), cv, new WorkspaceBackedRemapper(workspace, mappings)); // Shadow the parent type's remapper locally, // allowing us to use our more specific methods with additional context. this.workspaceRemapper = ((WorkspaceBackedRemapper) super.remapper); } @Override public MethodVisitor visitMethod(int access, @Nonnull String name, @Nonnull String descriptor, @Nullable String signature, @Nullable String[] exceptions) { // Adapted from base ClassRemapper implementation. // This allows us to skip calls to super 'visitMethod' to bypass calls to 'createMethodRemapper' // since our visitor we want to make needs information accessible here, but not in that method. String remappedDescriptor = remapper.mapMethodDesc(descriptor); MethodVisitor mv = cv == null ? null : cv.visitMethod( access, remapper.mapMethodName(className, name, descriptor), remappedDescriptor, remapper.mapSignature(signature, false), exceptions == null ? null : remapper.mapTypes(exceptions)); return mv == null ? null : new VariableRenamingMethodVisitor(className, name, descriptor, mv, workspaceRemapper); } @Override protected MethodVisitor createMethodRemapper(@Nullable MethodVisitor mv) { throw new IllegalStateException("Enhanced 'visitMethod(...)' usage required, 'createMethodMapper(...)' should never be called"); } /** * @return {@code true} when any mapping has been found and used. */ public boolean hasMappingBeenApplied() { return workspaceRemapper.hasMappingBeenApplied(); } /** * {@link MethodRemapper} pointing to enhanced remapping methods to allow for more context-sensitive behavior. * Method visitor that functions as an adapter for the * {@link #visitMethod(int, String, String, String, String[]) standard method remapping visitor} * that additionally supports variable renaming. */ private class VariableRenamingMethodVisitor extends MethodRemapper { private final String methodOwner; private final String methodName; private final String methodDesc; public VariableRenamingMethodVisitor(@Nonnull String methodOwner, @Nonnull String methodName, @Nonnull String methodDesc, @Nullable MethodVisitor mv, @Nonnull Remapper remapper) { super(RecafConstants.getAsmVersion(), mv, remapper); this.methodOwner = methodOwner; this.methodName = methodName; this.methodDesc = methodDesc; } @Override public void visitInvokeDynamicInsn(String name, String descriptor, Handle bootstrapMethodHandle, Object... bootstrapMethodArguments) { // Modified variant of impl in 'MethodRemapper' to call our 'workspaceRemapper' specific methods Object[] remappedBootstrapMethodArguments = new Object[bootstrapMethodArguments.length]; for (int i = 0; i < bootstrapMethodArguments.length; ++i) { remappedBootstrapMethodArguments[i] = remapper.mapValue(bootstrapMethodArguments[i]); } mv.visitInvokeDynamicInsn( workspaceRemapper.mapInvokeDynamicMethodName(name, descriptor, bootstrapMethodHandle, remappedBootstrapMethodArguments), remapper.mapMethodDesc(descriptor), (Handle) remapper.mapValue(bootstrapMethodHandle), remappedBootstrapMethodArguments); } @Override public void visitLocalVariable(String name, String desc, String signature, Label start, Label end, int index) { String mappedName = workspaceRemapper.mapVariableName(methodOwner, methodName, methodDesc, name, desc, index); super.visitLocalVariable(mappedName, desc, signature, start, end, index); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/aggregate/AggregateMappingManager.java ================================================ package software.coley.recaf.services.mapping.aggregate; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.slf4j.Logger; import software.coley.collections.Unchecked; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.behavior.PrioritySortable; import software.coley.recaf.cdi.EagerInitialization; import software.coley.recaf.services.Service; import software.coley.recaf.services.mapping.Mappings; import software.coley.recaf.services.workspace.WorkspaceCloseListener; import software.coley.recaf.services.workspace.WorkspaceManager; import software.coley.recaf.services.workspace.WorkspaceOpenListener; import software.coley.recaf.workspace.model.Workspace; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; /** * Manages tracking the state of mappings over time. * * @author Matt Coley * @author Marius Renner */ @EagerInitialization @ApplicationScoped public class AggregateMappingManager implements Service { public static final String SERVICE_ID = "mapping-aggregator"; private static final Logger logger = Logging.get(AggregateMappingManager.class); private final List aggregateListeners = new CopyOnWriteArrayList<>(); private final AggregateMappingManagerConfig config; private AggregatedMappings aggregatedMappings; @Inject public AggregateMappingManager(@Nonnull AggregateMappingManagerConfig config, @Nonnull WorkspaceManager workspaceManager) { this.config = config; ListenerHost host = new ListenerHost(); workspaceManager.addWorkspaceOpenListener(host); workspaceManager.addWorkspaceCloseListener(host); } /** * Update the aggregate mappings for the workspace. * * @param newMappings * The additional mappings that were added. */ public void updateAggregateMappings(@Nonnull Mappings newMappings) { if (aggregatedMappings == null) return; aggregatedMappings.update(newMappings); Unchecked.checkedForEach(aggregateListeners, listener -> listener.onAggregatedMappingsUpdated(getAggregatedMappings()), (listener, t) -> logger.error("Exception thrown when updating aggregate mappings", t)); } /** * Clears all mapping information. */ private void clearAggregated() { if (aggregatedMappings == null) return; aggregatedMappings.clear(); Unchecked.checkedForEach(aggregateListeners, listener -> listener.onAggregatedMappingsUpdated(getAggregatedMappings()), (listener, t) -> logger.error("Exception thrown when updating aggregate mappings", t)); } /** * @param listener * Listener to add. */ public void addAggregatedMappingsListener(@Nonnull AggregatedMappingsListener listener) { PrioritySortable.add(aggregateListeners, listener); } /** * @param listener * Listener to remove. * * @return {@code true} when the listener was removed. * {@code false} if the listener was not in the list. */ public boolean removeAggregatedMappingListener(@Nonnull AggregatedMappingsListener listener) { return aggregateListeners.remove(listener); } /** * @return Current aggregated mappings in the ASM format. Will be {@code null} if no workspace is open. */ @Nullable public AggregatedMappings getAggregatedMappings() { return aggregatedMappings; } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public AggregateMappingManagerConfig getServiceConfig() { return config; } private class ListenerHost implements WorkspaceOpenListener, WorkspaceCloseListener { @Override public void onWorkspaceOpened(@Nonnull Workspace workspace) { aggregatedMappings = new AggregatedMappings(workspace); } @Override public void onWorkspaceClosed(@Nonnull Workspace workspace) { aggregatedMappings = null; aggregateListeners.clear(); clearAggregated(); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/aggregate/AggregateMappingManagerConfig.java ================================================ package software.coley.recaf.services.mapping.aggregate; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link AggregateMappingManager}. * * @author Matt Coley */ @ApplicationScoped public class AggregateMappingManagerConfig extends BasicConfigContainer implements ServiceConfig { @Inject public AggregateMappingManagerConfig() { super(ConfigGroups.SERVICE_MAPPING, AggregateMappingManager.SERVICE_ID + CONFIG_SUFFIX); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/aggregate/AggregatedMappings.java ================================================ package software.coley.recaf.services.mapping.aggregate; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.services.mapping.IntermediateMappings; import software.coley.recaf.services.mapping.Mappings; import software.coley.recaf.services.mapping.WorkspaceBackedRemapper; import software.coley.recaf.services.mapping.data.ClassMapping; import software.coley.recaf.services.mapping.data.FieldMapping; import software.coley.recaf.services.mapping.data.MemberMapping; import software.coley.recaf.services.mapping.data.MethodMapping; import software.coley.recaf.services.mapping.data.VariableMapping; import software.coley.recaf.workspace.model.Workspace; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; /** * Mappings implementation for internal tracking of aggregated mappings. *
* This will handle transitive renames such as ({@code a -> b -> c} by * compressing them down to their ultimate result ({@code a -> c}). * We will be referring to classes in these stages of naming as "a", "b", and "c" in the logic to keep this * example as the basis of all operations. *
* When this is done for every update of the mappings, the resulting mapping can be applied to the original * class files to achieve the same result again. * * @author Matt Coley * @author Marius Renner */ public class AggregatedMappings extends IntermediateMappings { private final Map reverseOrderClassMapping = new ConcurrentHashMap<>(); private final WorkspaceBackedRemapper reverseMapper; private boolean missingFieldDescriptors; /** * @param workspace * Workspace associated with the aggregate mappings. */ public AggregatedMappings(@Nonnull Workspace workspace) { reverseMapper = new WorkspaceBackedRemapper(workspace, this) { @Override public String map(String internalName) { String reverseName = reverseOrderClassMapping.get(internalName); if (reverseName != null) { markModified(); return reverseName; } return internalName; } }; } /** * Lookup the original name of a mapped class. * * @param name * Current class name. * * @return Original class name. * {@code null} if the class has not been mapped. */ @Nullable public String getReverseClassMapping(@Nonnull String name) { return reverseOrderClassMapping.get(name); } /** * @param owner * Current class name. * @param fieldName * Current field name. * @param fieldDesc * Current field descriptor. * * @return Original name of the field if any mappings exist. * {@code null} if the field has not been mapped. */ @Nullable public String getReverseFieldMapping(@Nonnull String owner, @Nonnull String fieldName, @Nonnull String fieldDesc) { String originalOwnerName = getReverseClassMapping(owner); if (originalOwnerName == null) originalOwnerName = owner; // Get fields in the original class List fieldMappings = fields.get(originalOwnerName); if (fieldMappings == null || fieldMappings.isEmpty()) return null; // Find a matching field String originalDesc = reverseMapper.mapDesc(fieldDesc); for (FieldMapping fieldMapping : fieldMappings) { // The current name must match the mappings "new" name if (!fieldName.equals(fieldMapping.getNewName())) continue; // The original field descriptor must match the mapping key's if (originalDesc.equals(fieldMapping.getDesc())) return fieldMapping.getOldName(); } return null; } /** * @param owner * Current class name. * @param methodName * Current method name. * @param methodDesc * Current method descriptor. * * @return Original name of the method if any mappings exist. * {@code null} if the method has not been mapped. */ @Nullable public String getReverseMethodMapping(@Nonnull String owner, @Nonnull String methodName, @Nonnull String methodDesc) { String originalOwnerName = getReverseClassMapping(owner); if (originalOwnerName == null) originalOwnerName = owner; // Get methods in the original class List methodMappings = methods.get(originalOwnerName); if (methodMappings == null || methodMappings.isEmpty()) return null; // Find a matching method String originalDesc = reverseMapper.mapDesc(methodDesc); for (MethodMapping methodMapping : methodMappings) { // The current name must match the mappings "new" name if (!methodName.equals(methodMapping.getNewName())) continue; // The original method descriptor must match the mapping key's if (originalDesc.equals(methodMapping.getDesc())) return methodMapping.getOldName(); } return null; } /** * @param owner * Current class name. * @param methodName * Current method name. * @param methodDesc * Current method descriptor. * @param varName * Current variable name. * @param varDesc * Current variable descriptor. * @param varIndex * Variable index. * * @return Original name of the variable if any mappings exist. * {@code null} if the variable has not been mapped. */ @Nullable public String getReverseVariableMapping(@Nonnull String owner, @Nonnull String methodName, @Nonnull String methodDesc, @Nonnull String varName, @Nonnull String varDesc, int varIndex) { String originalOwnerName = getReverseClassMapping(owner); if (originalOwnerName == null) originalOwnerName = owner; // Get methods in the original class List methodMappings = methods.get(originalOwnerName); if (methodMappings == null || methodMappings.isEmpty()) return null; String originalMethodDesc = reverseMapper.mapDesc(methodDesc); String originalVarDesc = reverseMapper.mapDesc(varDesc); for (MethodMapping methodMapping : methodMappings) { // The current name must match the mappings "new" name if (!methodName.equals(methodMapping.getNewName())) continue; // The original method descriptor must match the mapping key's if (originalMethodDesc.equals(methodMapping.getDesc())) { // Get the variables that were mapped under the original name List variableMappings = variables.get(varKey(originalOwnerName, methodMapping.getOldName(), originalMethodDesc)); for (VariableMapping variableMapping : variableMappings) { // If the variable index, name, and descriptor match, yield the variable mapping's original name if (variableMapping.getIndex() == varIndex && variableMapping.getNewName().equals(varName)) { if (varDesc.equals(originalVarDesc)) { return variableMapping.getOldName(); } } } } } return null; } /** * @param desc * Descriptor with remapped type names. * * @return Descriptor with pre-mapped/original type names. */ @Nullable public String applyReverseMappings(@Nullable String desc) { if (desc == null) return null; else if (desc.charAt(0) == '(') return reverseMapper.mapMethodDesc(desc); else return reverseMapper.mapDesc(desc); } @Override public void addClass(@Nonnull String oldName, @Nonnull String newName) { super.addClass(oldName, newName); reverseOrderClassMapping.put(newName, oldName); } /** * Some mapping formats do not include the descriptor of fields since they assume all fields are uniquely identified by name. * This kinda sucks because unless we do a lot of additional lookup work (Which may not even be successful), * we're missing out on data. If this is ever the case formats that do require this data cannot be exported to. * * @return {@code true} when there are field entries in these mapping which do not have descriptors associated with them. */ public boolean isMissingFieldDescriptors() { return missingFieldDescriptors; } /** * Clears the mapping entries. */ public void clear() { missingFieldDescriptors = false; classes.clear(); fields.clear(); methods.clear(); variables.clear(); } /** * Updates aggregated mappings with new values. * * @param newMappings * The additional mappings that were added. They will be in the form of {@code b -> c}. * We need to make sure we map the key type {@code b} to {@code a}. * * @return {@code true} when the mapping operation required bridging a current class name to its original name. */ public boolean update(@Nonnull Mappings newMappings) { // ORIGINAL: // a -> b // a.f1 -> b.f2 // MAPPING: // b -> c // b.f2 -> c.f3 // AGGREGATED: // a -> c // a.f1 -> f3 boolean bridged; IntermediateMappings intermediate = newMappings.exportIntermediate(); bridged = updateClasses(intermediate.getClasses()); bridged |= updateMembers(intermediate.getFields().values()); bridged |= updateMembers(intermediate.getMethods().values()); bridged |= updateVariables(intermediate.getVariables().values()); return bridged; } private boolean updateClasses(@Nonnull Map classes) { boolean bridged = false; for (ClassMapping newMapping : classes.values()) { String cName = newMapping.getNewName(); String bName = newMapping.getOldName(); String aName = reverseOrderClassMapping.get(bName); if (aName != null) { // There is a prior entry of the class, 'aName' thus we use it as the key // and not 'bName' since that was the prior value the mapping for 'aName'. bridged = true; addClass(aName, cName); } else { // No prior entry of the class. addClass(bName, cName); } } return bridged; } private boolean updateMembers(@Nonnull Collection> newMappings) { // With members, we need to take special care, for example: // 1. a --> b // 2. b.x --> b.y // Now we need to ensure the mapping "a.x --> b.y" exists. boolean bridged = false; for (List members : newMappings) { for (MemberMapping newMemberMapping : members) { String bOwnerName = newMemberMapping.getOwnerName(); String aOwnerName = reverseOrderClassMapping.get(bOwnerName); String oldMemberName = newMemberMapping.getOldName(); String newMemberName = newMemberMapping.getNewName(); String desc = newMemberMapping.getDesc(); String owner = bOwnerName; if (aOwnerName != null) { // We need to map the member current mapped owner name to the // original owner's name. bridged = true; owner = aOwnerName; oldMemberName = findPriorMemberName(aOwnerName, newMemberMapping); } // Desc must always be checked for updates desc = applyReverseMappings(desc); // Add bridged entry if (newMemberMapping.isField()) { missingFieldDescriptors |= desc == null; addField(owner, desc, oldMemberName, newMemberName); } else if (desc != null) { addMethod(owner, desc, oldMemberName, newMemberName); } } } return bridged; } private boolean updateVariables(@Nonnull Collection> newMappings) { // a.foo() var x // ... // a.foo() --> b.foo() // b.foo() --> b.bar() // b.bar() var x --> var z // ... // a.foo() var x --> var z boolean bridged = false; /* TODO: Aggregate variable mappings for (List variableMappings : newMappings) { for (VariableMapping newVariableMapping : variableMappings) { String bOwner = newVariableMapping.getOwnerName(); String bMethodName = newVariableMapping.getMethodName(); String bMethodDesc = newVariableMapping.getMethodDesc(); } } */ return bridged; } @Nonnull private String findPriorMemberName(@Nonnull String oldClassName, @Nonnull MemberMapping memberMapping) { if (memberMapping.isField()) { return findPriorName(memberMapping, getClassFieldMappings(oldClassName)); } else { return findPriorName(memberMapping, getClassMethodMappings(oldClassName)); } } @Nonnull private String findPriorName(@Nonnull MemberMapping newMethodMapping, @Nonnull List members) { // If the old name not previously mapped, then it's the same as what the new mapping has given. // So the passed new mapping is a safe default. MemberMapping target = newMethodMapping; String unmappedDesc = applyReverseMappings(newMethodMapping.getDesc()); for (MemberMapping oldMethodMapping : members) { // The old name must be the new mapping's base name. // The descriptor types must also match. if (oldMethodMapping.getNewName().equals(newMethodMapping.getOldName()) && Objects.equals(oldMethodMapping.getDesc(), unmappedDesc)) { target = oldMethodMapping; break; } } // Remove old mapping entry members.remove(target); return target.getOldName(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/aggregate/AggregatedMappingsListener.java ================================================ package software.coley.recaf.services.mapping.aggregate; import jakarta.annotation.Nonnull; import software.coley.recaf.behavior.PrioritySortable; /** * Listener for when the {@link AggregatedMappings aggregated mappings} are updated. * * @author Matt Coley */ public interface AggregatedMappingsListener extends PrioritySortable { /** * Any update to the aggregated mappings will call this. * * @param mappings * Current aggregated mappings. */ void onAggregatedMappingsUpdated(@Nonnull AggregatedMappings mappings); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/data/AbstractMappingKey.java ================================================ package software.coley.recaf.services.mapping.data; import jakarta.annotation.Nonnull; /** * Base mapping key. * * @author xDark */ public abstract class AbstractMappingKey implements MappingKey { private String text; @Nonnull @Override public String getAsText() { String text = this.text; if (text == null) { return this.text = toText(); } return text; } @Override public int compareTo(MappingKey o) { return getAsText().compareTo(o.getAsText()); } @Override public String toString() { return getAsText(); } @Nonnull protected abstract String toText(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/data/ClassMapping.java ================================================ package software.coley.recaf.services.mapping.data; import jakarta.annotation.Nonnull; import java.util.Objects; /** * Outlines mappings for a class. * * @author Matt Coley */ public class ClassMapping { private final String oldName; private final String newName; /** * @param oldName * Pre-mapping name. * @param newName * Post-mapping name. */ public ClassMapping(@Nonnull String oldName, @Nonnull String newName) { this.oldName = oldName; this.newName = newName; } /** * @return Pre-mapping name. */ @Nonnull public String getOldName() { return oldName; } /** * @return Post-mapping name. */ @Nonnull public String getNewName() { return newName; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; ClassMapping that = (ClassMapping) o; return oldName.equals(that.oldName) && newName.equals(that.newName); } @Override public int hashCode() { return Objects.hash(oldName, newName); } @Override public String toString() { return oldName + " ==> " + newName; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/data/ClassMappingKey.java ================================================ package software.coley.recaf.services.mapping.data; import jakarta.annotation.Nonnull; /** * Mapping key for classes. * * @author xDark */ public class ClassMappingKey extends AbstractMappingKey { private final String name; /** * @param name * Class name. */ public ClassMappingKey(@Nonnull String name) { this.name = name; } /** * @return Class name. */ @Nonnull public String getName() { return name; } @Nonnull @Override protected String toText() { return name; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof ClassMappingKey)) return false; ClassMappingKey that = (ClassMappingKey) o; return name.equals(that.name); } @Override public int hashCode() { return name.hashCode(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/data/FieldMapping.java ================================================ package software.coley.recaf.services.mapping.data; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import java.util.Objects; /** * Outlines mappings for a field. * * @author Matt Coley */ public class FieldMapping implements MemberMapping { private final String ownerName; private final String desc; private final String oldName; private final String newName; /** * @param ownerName * Name of class defining the field. * @param oldName * Pre-mapping field name. * @param newName * Post-mapping field name. */ public FieldMapping(@Nonnull String ownerName, @Nonnull String oldName, @Nonnull String newName) { this(ownerName, oldName, null, newName); } /** * @param ownerName * Name of class defining the field. * @param oldName * Pre-mapping field name. * @param desc * Descriptor type of the field. * May be {@code null} since not all formats use it. * @param newName * Post-mapping field name. */ public FieldMapping(@Nonnull String ownerName, @Nonnull String oldName, @Nullable String desc, @Nonnull String newName) { this.ownerName = ownerName; this.oldName = oldName; this.newName = newName; this.desc = desc; } @Nonnull @Override public String getOwnerName() { return ownerName; } @Nullable @Override public String getDesc() { return desc; } @Nonnull @Override public String getOldName() { return oldName; } @Nonnull @Override public String getNewName() { return newName; } @Override public boolean isField() { return true; } /** * @return {@code true} when there is no associated type from {@link #getDesc()}. */ public boolean hasType() { return desc != null; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; FieldMapping that = (FieldMapping) o; return ownerName.equals(that.ownerName) && Objects.equals(desc, that.desc) && oldName.equals(that.oldName) && newName.equals(that.newName); } @Override public int hashCode() { return Objects.hash(ownerName, desc, oldName, newName); } @Override public String toString() { return oldName + " ==> " + newName; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/data/FieldMappingKey.java ================================================ package software.coley.recaf.services.mapping.data; import jakarta.annotation.Nonnull; import java.util.Objects; /** * Mapping key for fields. * * @author xDark */ public class FieldMappingKey extends AbstractMappingKey { private final String owner; private final String name; private final String desc; /** * @param owner * Class name. * @param name * Field name. * @param desc * Field descriptor. */ public FieldMappingKey(String owner, String name, String desc) { this.owner = owner; this.name = name; this.desc = desc; } /** * @return Class owner. */ public String getOwner() { return owner; } /** * @return Field name. */ public String getName() { return name; } /** * @return Field descriptor. */ public String getDesc() { return desc; } @Nonnull @Override protected String toText() { String owner = this.owner; String name = this.name; String desc = this.desc; if (desc == null) { return owner + '\t' + name; } return owner + '\t' + name + '\t' + desc; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof FieldMappingKey)) return false; FieldMappingKey that = (FieldMappingKey) o; return owner.equals(that.owner) && name.equals(that.name) && Objects.equals(desc, that.desc); } @Override public int hashCode() { int result = owner.hashCode(); result = 31 * result + name.hashCode(); result = 31 * result + Objects.hashCode(desc); return result; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/data/MappingKey.java ================================================ package software.coley.recaf.services.mapping.data; import jakarta.annotation.Nonnull; /** * Mapping key, may be a class, method, * field or a local variable. * * @author xDark */ public interface MappingKey extends Comparable { @Nonnull String getAsText(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/data/MemberMapping.java ================================================ package software.coley.recaf.services.mapping.data; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; public interface MemberMapping { /** * @return Name of class defining the member. */ @Nonnull String getOwnerName(); /** * @return Descriptor type of the member. * May be {@code null} for fields with some mapping implementations. */ @Nullable String getDesc(); /** * @return Pre-mapping member name. */ @Nonnull String getOldName(); /** * @return Post-mapping member name. */ @Nonnull String getNewName(); /** * @return {@code true} when the member is a field. * {@code false} for methods. */ boolean isField(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/data/MethodMapping.java ================================================ package software.coley.recaf.services.mapping.data; import jakarta.annotation.Nonnull; import java.util.Objects; /** * Outlines mappings for a method. * * @author Matt Coley */ public class MethodMapping implements MemberMapping { private final String ownerName; private final String desc; private final String oldName; private final String newName; /** * @param ownerName * Name of class defining the method. * @param oldName * Pre-mapping method name. * @param desc * Descriptor type of the method. * @param newName * Post-mapping method name. */ public MethodMapping(@Nonnull String ownerName, @Nonnull String oldName, @Nonnull String desc, @Nonnull String newName) { this.ownerName = ownerName; this.desc = desc; this.oldName = oldName; this.newName = newName; } @Nonnull @Override public String getOwnerName() { return ownerName; } @Nonnull @Override public String getDesc() { return desc; } @Nonnull @Override public String getOldName() { return oldName; } @Nonnull @Override public String getNewName() { return newName; } @Override public boolean isField() { return false; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; MethodMapping that = (MethodMapping) o; return ownerName.equals(that.ownerName) && desc.equals(that.desc) && oldName.equals(that.oldName) && newName.equals(that.newName); } @Override public int hashCode() { return Objects.hash(ownerName, desc, oldName, newName); } @Override public String toString() { return oldName + " ==> " + newName; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/data/MethodMappingKey.java ================================================ package software.coley.recaf.services.mapping.data; import jakarta.annotation.Nonnull; import java.util.Objects; /** * Mapping key for methods. * * @author xDark */ public class MethodMappingKey extends AbstractMappingKey { private final String owner; private final String name; private final String desc; /** * @param owner * Class name. * @param name * Method name. * @param desc * Method descriptor. */ public MethodMappingKey(String owner, String name, String desc) { this.owner = owner; this.name = name; this.desc = desc; } /** * @return Class owner. */ public String getOwner() { return owner; } /** * @return Method name. */ public String getName() { return name; } /** * @return Method descriptor. */ public String getDesc() { return desc; } @Nonnull @Override protected String toText() { String owner = this.owner; String name = this.name; String desc = this.desc; if (desc == null) { return owner + '\t' + name; } return owner + '\t' + name + '\t' + desc; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof MethodMappingKey)) return false; MethodMappingKey that = (MethodMappingKey) o; return owner.equals(that.owner) && name.equals(that.name) && Objects.equals(desc, that.desc); } @Override public int hashCode() { int result = owner.hashCode(); result = 31 * result + name.hashCode(); result = 31 * result + Objects.hashCode(desc); return result; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/data/VariableMapping.java ================================================ package software.coley.recaf.services.mapping.data; import java.util.Objects; /** * Outlines mappings for a variable. * * @author Matt Coley */ public class VariableMapping { private final String ownerName; private final String methodName; private final String methodDesc; // Variable data private final String oldName; private final String desc; private final int index; private final String newName; /** * @param ownerName * Name of class defining the method. * @param methodName * Pre-mapping method name. * @param methodDesc * Descriptor type of the method. * @param desc * Variable descriptor. * @param oldName * Variable old name. * @param index * Variable index. * @param newName * Post-mapping method name. */ public VariableMapping(String ownerName, String methodName, String methodDesc, String desc, String oldName, int index, String newName) { this.ownerName = Objects.requireNonNull(ownerName, "Mapping entries cannot be null"); this.methodDesc = Objects.requireNonNull(methodDesc, "Mapping entries cannot be null"); this.methodName = Objects.requireNonNull(methodName, "Mapping entries cannot be null"); this.newName = Objects.requireNonNull(newName, "Mapping entries cannot be null"); // Variable info, may be null this.desc = desc; this.oldName = oldName; this.index = index; } /** * @return Name of class defining the method. */ public String getOwnerName() { return ownerName; } /** * @return Method name. */ public String getMethodName() { return methodName; } /** * @return Method descriptor. */ public String getMethodDesc() { return methodDesc; } /** * @return Old variable name. May be {@code null}. */ public String getOldName() { return oldName; } /** * @return Variable descriptor. May be {@code null}. */ public String getDesc() { return desc; } /** * @return Variable descriptor. May be {@code -1} for unknown values. */ public int getIndex() { return index; } /** * @return New variable name. */ public String getNewName() { return newName; } @Override public String toString() { return oldName + " ==> " + newName; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/data/VariableMappingKey.java ================================================ package software.coley.recaf.services.mapping.data; import jakarta.annotation.Nonnull; import java.util.Objects; /** * Mapping key for variable. * * @author xDark */ public class VariableMappingKey extends AbstractMappingKey { private final String owner; private final String methodName; private final String methodDesc; private final String variableName; private final String variableDesc; /** * @param owner * Class name. * @param methodName * Method name. * @param methodDesc * Method descriptor. * @param variableName * Variable name. * @param variableDesc * Variable descriptor. */ public VariableMappingKey(String owner, String methodName, String methodDesc, String variableName, String variableDesc) { this.owner = owner; this.methodName = methodName; this.methodDesc = methodDesc; this.variableName = variableName; this.variableDesc = variableDesc; } @Nonnull @Override protected String toText() { String owner = this.owner; String methodName = this.methodName; String methodDesc = Objects.toString(this.methodDesc); String variableName = this.variableName; String variableDesc = this.variableDesc; StringBuilder builder = new StringBuilder(owner.length() + methodName.length() + methodDesc.length() + variableName.length() + 5); builder.append(owner).append('\t'); builder.append(methodName).append('\t'); builder.append(methodDesc).append('\t'); builder.append(variableName); if (variableDesc != null) { builder.append('\t').append(variableDesc); } return builder.toString(); } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof VariableMappingKey)) return false; VariableMappingKey that = (VariableMappingKey) o; return owner.equals(that.owner) && methodName.equals(that.methodName) && Objects.equals(methodDesc, that.methodDesc) && variableName.equals(that.variableName) && Objects.equals(variableDesc, that.variableDesc); } @Override public int hashCode() { int result = owner.hashCode(); result = 31 * result + methodName.hashCode(); result = 31 * result + Objects.hashCode(methodDesc); result = 31 * result + variableName.hashCode(); result = 31 * result + Objects.hashCode(variableDesc); return result; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/format/AbstractMappingFileFormat.java ================================================ package software.coley.recaf.services.mapping.format; import jakarta.annotation.Nonnull; /** * Common base for mapping file format values. * * @author Matt Coley */ public abstract class AbstractMappingFileFormat implements MappingFileFormat { private final String implementationName; private final boolean supportFieldTypeDifferentiation; private final boolean supportVariableTypeDifferentiation; protected AbstractMappingFileFormat(String implementationName, boolean supportFieldTypeDifferentiation, boolean supportVariableTypeDifferentiation) { this.implementationName = implementationName; this.supportFieldTypeDifferentiation = supportFieldTypeDifferentiation; this.supportVariableTypeDifferentiation = supportVariableTypeDifferentiation; } @Nonnull @Override public String implementationName() { return implementationName; } @Override public boolean doesSupportFieldTypeDifferentiation() { return supportFieldTypeDifferentiation; } @Override public boolean doesSupportVariableTypeDifferentiation() { return supportVariableTypeDifferentiation; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/format/EnigmaMappings.java ================================================ package software.coley.recaf.services.mapping.format; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import org.slf4j.Logger; import software.coley.collections.tuple.Pair; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.services.mapping.IntermediateMappings; import software.coley.recaf.services.mapping.Mappings; import software.coley.recaf.services.mapping.data.ClassMapping; import software.coley.recaf.services.mapping.data.FieldMapping; import software.coley.recaf.services.mapping.data.MethodMapping; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayDeque; import java.util.Deque; import java.util.function.Supplier; import java.util.stream.Stream; /** * Enigma mappings file implementation. *

* Specification: enigma_mappings * * @author Janmm14 * @author Matt Coley */ @Dependent public class EnigmaMappings extends AbstractMappingFileFormat { public static final String NAME = "Enigma"; private static final String FAIL = "Invalid Enigma mappings, "; private static final Logger LOGGER = Logging.get(EnigmaMappings.class); // Parser phase constants private static final int PHASE_IGNORE_LINE = 0; private static final int PHASE_FIND_TYPE = 1; private static final int PHASE_TYPE_CLASS = 2; private static final int PHASE_TYPE_FIELD = 3; private static final int PHASE_TYPE_METHOD = 4; // The finishing flag needs to be higher than the highest phase, as it is an additive flag private static final int PHASE_TYPE_FLAG_FINISH = 8; /** * New enigma instance. */ public EnigmaMappings() { super(NAME, true, true); } /** * Parses an Enigma file, or Enigma directory containing multiple enigma mapping files, suffixed with {@code .mapping}. *
* See for instance: FabricMC/yarn * * @param path * Root file/directory of enigma mappings. * * @return Intermediate mappings from parsed enigma file/directory. * * @throws InvalidMappingException * When reading the mappings encounters any failure. */ @Nonnull public IntermediateMappings parse(@Nonnull Path path) throws InvalidMappingException { if (Files.isRegularFile(path)) { try { return parse(Files.readString(path)); } catch (IOException ex) { throw new InvalidMappingException(ex); } } IntermediateMappings sum = new IntermediateMappings(); try (Stream files = Files.walk(path).filter(p -> p.getFileName().toString().endsWith(".mapping"))) { files.forEach(p -> { try { String fileContents = Files.readString(p); IntermediateMappings mappings = parse(fileContents); sum.putAll(mappings); } catch (Exception ex) { // Rethrow so outer catch will handle throw new IllegalStateException(ex); } }); } catch (Throwable ex) { throw new InvalidMappingException("Failed to walk enigma directory: " + path, ex); } return sum; } @Nonnull @Override public IntermediateMappings parse(@Nonnull String mappingsText) throws InvalidMappingException { return parseEnigma(mappingsText); } /** * @param mappingsText * Text of the mappings to parse. * * @return Intermediate mappings from parsed text. * * @throws InvalidMappingException * When reading the mappings encounters any failure. */ @Nonnull public static IntermediateMappings parseEnigma(@Nonnull String mappingsText) throws InvalidMappingException { // COMMENT comment #ignored // CLASS BaseClass TargetClass // FIELD baseField targetField baseDesc // METHOD baseMethod targetMethod baseMethodDesc // ARG baseArg targetArg #ignored // CLASS 1 InnerClass // FIELD innerField targetField innerDesc IntermediateMappings mappings = new IntermediateMappings(); Deque> currentClass = new ArrayDeque<>(); int line = 1; for (int i = 0, len = mappingsText.length(); i < len; ) { // i incremented inside the loop // count \t int indent = 0; for (; i < len; i++) { char c = mappingsText.charAt(i); if (c == '\t') { indent++; continue; } break; } // parse line i = handleLine(line, indent, i, mappingsText, currentClass, mappings); // go to next line if (i < len) { char c = mappingsText.charAt(i); assert c == '\n' || c == '\r' : "Expected newline, got <" + c + "> (" + ((int) c) + ") @line " + line + " @char " + i; line++; if (c == '\r') { int ip1 = i + 1; if (ip1 < len && mappingsText.charAt(ip1) == '\n') { i++; } } i++; } } return mappings; } /** * @param line * Current line number in the mappings file (1 based) * @param indent * Current level of indentation. Should be equal to or less than the size of the {@code currentClass} {@link Deque}. * @param i * Current offset into the mappings file. * @param mappingsText * Mappings file contents. * @param currentClass * Deque of the current 'context' (what class are we building mappings for). * @param mappings * Output mappings. * * @return Updated offset into the mappings file. * * @throws InvalidMappingException * When reading the mappings encounters any failure. */ private static int handleLine(int line, int indent, int i, @Nonnull String mappingsText, @Nonnull Deque> currentClass, @Nonnull IntermediateMappings mappings) throws InvalidMappingException { // read next token String lineType = mappingsText.substring(i = skipSpace(i, mappingsText), i = readToken(i, mappingsText)); switch (lineType) { case "CLASS" -> { updateIndent(currentClass, indent, () -> ("Invalid Enigma mappings, CLASS indent level " + indent + " too deep (expected max. " + currentClass.size() + ", " + currentClass + ") @line " + line + " @char "), i); String classNameA = mappingsText.substring(i = skipSpace(i, mappingsText), i = readToken(i, mappingsText)); classNameA = removeNonePackage(classNameA); classNameA = qualifyWithOuterClassesA(currentClass, classNameA); String classNameB = mappingsText.substring(i = skipSpace(i, mappingsText), i = readToken(i, mappingsText)); if (classNameB.isEmpty() || "-".equals(classNameB) || classNameB.startsWith("ACC:")) { // no mapping for class, but need to include for context for following members classNameB = classNameA; } else { classNameB = removeNonePackage(classNameB); classNameB = qualifyWithOuterClassesB(currentClass, classNameB); mappings.addClass(classNameA, classNameB); } currentClass.push(new Pair<>(classNameA, classNameB)); } case "FIELD" -> i = handleClassMemberMapping(line, indent, i, mappingsText, currentClass, "FIELD", mappings::addField); case "METHOD" -> i = handleClassMemberMapping(line, indent, i, mappingsText, currentClass, "METHOD", mappings::addMethod); } i = skipLineRest(i, mappingsText); return i; } /** * @param line * Current line number in the mappings file (1 based) * @param indent * Current level of indentation. Should be equal to or less than the size of the {@code currentClass} {@link Deque}. * @param i * Current offset into the mappings file. * @param mappingsText * Mappings file contents. * @param currentClass * Deque of the current 'context' (what class are we building mappings for). * @param type * The expected type of content we're handling. IE, {@code CLASS}, {@code FIELD}, or {@code METHOD}. * @param consumer * Consumer to record parsed mappings into. * * @return Updated offset into the mappings file. * * @throws InvalidMappingException * When reading the mappings encounters any failure. */ private static int handleClassMemberMapping(int line, int indent, int i, @Nonnull String mappingsText, @Nonnull Deque> currentClass, @Nonnull String type, @Nonnull MemberMappingsConsumer consumer) throws InvalidMappingException { // // = '' | '-' | updateIndent(currentClass, indent, () -> FAIL + type + " indent level " + indent + " too deep (expected max. " + currentClass.size() + ", " + currentClass + ") @line " + line + " @char ", i); if (currentClass.isEmpty()) { throw new InvalidMappingException(FAIL + type + " without class context @line " + line + " @char " + i); } String nameA = mappingsText.substring(i = skipSpace(i, mappingsText), i = readToken(i, mappingsText)); String nameB = mappingsText.substring(i = skipSpace(i, mappingsText), i = readToken(i, mappingsText)); // If can have mapping if (!nameB.isEmpty() && !"-".equals(nameB) && !nameB.startsWith("ACC:")) { String desc = mappingsText.substring(i = skipSpace(i, mappingsText), i = readToken(i, mappingsText)); if (desc.startsWith("ACC:")) { // skip optional access modifier desc = mappingsText.substring(i = skipSpace(i, mappingsText), i = readToken(i, mappingsText)); } if (desc.isEmpty()) { // no desc found = line contained only one name and optional access modifier = no mapping return i; } assert currentClass.peek() != null; // checked above consumer.accept(currentClass.peek().getLeft(), desc, nameA, nameB); } return i; } /** * @param currentClass * Deque of the current 'context' (what class are we building mappings for). * @param classNameA * Initial class name. * * @return Fully qualified class name based on the current context in the deque. */ @Nonnull private static String qualifyWithOuterClassesA(@Nonnull Deque> currentClass, @Nonnull String classNameA) { if (currentClass.isEmpty()) { return classNameA; } StringBuilder sb = new StringBuilder(); for (Pair pair : currentClass) { sb.append(pair.getLeft()).append('$'); } classNameA = sb.append(classNameA).toString(); return classNameA; } /** * @param currentClass * Deque of the current 'context' (what class are we building mappings for). * @param classNameB * Destination class name. * * @return Fully qualified class name based on the current context in the deque. */ @Nonnull private static String qualifyWithOuterClassesB(@Nonnull Deque> currentClass, @Nonnull String classNameB) { if (currentClass.isEmpty()) { return classNameB; } StringBuilder sb = new StringBuilder(); for (Pair pair : currentClass) { sb.append(pair.getRight()).append('$'); } classNameB = sb.append(classNameB).toString(); return classNameB; } /** * @param currentClass * Deque of the current 'context' (what class are we building mappings for). * @param indent * Current level of indentation. Should be equal to or less than the size of the {@code currentClass} {@link Deque}. * @param failStr * Message to include in the thrown invalid mapping exception if the indentation state is invalid. * @param i * Current offset into the mappings file. * * @throws InvalidMappingException * Thrown when the indentation state does not match the current class context. */ private static void updateIndent(@Nonnull Deque> currentClass, int indent, @Nonnull Supplier failStr, int i) throws InvalidMappingException { if (indent > currentClass.size()) { throw new InvalidMappingException(failStr.get() + i); } while (currentClass.size() > indent) { currentClass.pop(); } } /** * @param i * Current offset into the mappings file. * @param mappingsText * Mappings file contents. * * @return Updated offset into the mappings file, skipping to the end of the line. */ private static int skipLineRest(int i, @Nonnull String mappingsText) { for (int len = mappingsText.length(); i < len; i++) { char c = mappingsText.charAt(i); if (c == '\r' || c == '\n') { break; } } return i; } /** * @param i * Current offset into the mappings file. * @param mappingsText * Mappings file contents. * * @return Updated offset into the mappings file, skipping to the next non-space character. */ private static int skipSpace(int i, @Nonnull String mappingsText) { for (int len = mappingsText.length(); i < len; i++) { char c = mappingsText.charAt(i); if (c != ' ') { break; } } return i; } /** * @param i * Current offset into the mappings file. * @param mappingsText * Mappings file contents. * * @return Updated offset into the mappings file, skipping to the end of the token. * * @throws InvalidMappingException * When a tab is encountered (Unexpected indentation). */ private static int readToken(int i, @Nonnull String mappingsText) throws InvalidMappingException { // read until next space, newline, or comment for (int len = mappingsText.length(); i < len; i++) { char c = mappingsText.charAt(i); if (c == '\n' || c == '\r' || c == ' ' || c == '#') { break; } if (c == '\t') { throw new InvalidMappingException("Unexpected tab character @char " + i); } } return i; } @Override public String exportText(@Nonnull Mappings mappings) { //TODO: Fix inner class handling // - Currently we export inner classes as top-level classes // - We should match the spec and have inner-classes indented beneath their outer classes StringBuilder sb = new StringBuilder(); IntermediateMappings intermediate = mappings.exportIntermediate(); for (String oldClassName : intermediate.getClassesWithMappings()) { ClassMapping classMapping = intermediate.getClassMapping(oldClassName); if (classMapping != null) { String newClassName = classMapping.getNewName(); // CLASS BaseClass TargetClass sb.append("CLASS ") .append(oldClassName).append(' ') .append(newClassName).append("\n"); } else { // Not mapped, but need to include for context for following members sb.append("CLASS ") .append(oldClassName).append("\n"); } for (FieldMapping fieldMapping : intermediate.getClassFieldMappings(oldClassName)) { String oldFieldName = fieldMapping.getOldName(); String newFieldName = fieldMapping.getNewName(); String fieldDesc = fieldMapping.getDesc(); // FIELD baseField targetField baseDesc sb.append("\tFIELD ") .append(oldFieldName).append(' ') .append(newFieldName).append(' ') .append(fieldDesc).append("\n"); } for (MethodMapping methodMapping : intermediate.getClassMethodMappings(oldClassName)) { String oldMethodName = methodMapping.getOldName(); String newMethodName = methodMapping.getNewName(); String methodDesc = methodMapping.getDesc(); // METHOD baseMethod targetMethod baseMethodDesc sb.append("\tMETHOD ") .append(oldMethodName).append(' ') .append(newMethodName).append(' ') .append(methodDesc).append("\n"); } } return sb.toString(); } @Nonnull private static String removeNonePackage(@Nonnull String text) { return text.replaceAll("(?:^|(?<=L))none/", ""); } private interface MemberMappingsConsumer { void accept(String oldClassName, String desc, String oldName, String newName); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/format/InvalidMappingException.java ================================================ package software.coley.recaf.services.mapping.format; import jakarta.annotation.Nonnull; /** * Wrapper to encompass any error encountered during mapping format reading / writing. * * @author Matt Coley */ public class InvalidMappingException extends Exception { /** * @param cause * Cause for mapping parse/write failure. */ public InvalidMappingException(@Nonnull Throwable cause) { super(cause); } /** * @param message * Detail message for why the mappings are invalid. */ public InvalidMappingException(@Nonnull String message) { super(message); } /** * @param message * Detail message for why the mappings are invalid. * @param cause * Cause for mapping parse/write failure. */ public InvalidMappingException(@Nonnull String message, @Nonnull Throwable cause) { super(message, cause); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/format/JadxMappings.java ================================================ package software.coley.recaf.services.mapping.format; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import software.coley.recaf.services.mapping.IntermediateMappings; import software.coley.recaf.services.mapping.Mappings; import software.coley.recaf.services.mapping.data.ClassMapping; import software.coley.recaf.services.mapping.data.FieldMapping; import software.coley.recaf.services.mapping.data.MethodMapping; import software.coley.recaf.util.StringUtil; /** * Jadx mappings file implementation. *
* This format type is no longer used as of Jadx 1.4.2. * Instead, Jadx now uses Engima & TinyV2. * * @author Matt Coley */ @Dependent public class JadxMappings extends AbstractMappingFileFormat { public static final String NAME = "Jadx (Legacy)"; /** * New jadx instance. */ public JadxMappings() { super(NAME, true, true); } @Nonnull @Override public IntermediateMappings parse(@Nonnull String mappingText) { IntermediateMappings mappings = new IntermediateMappings(); String[] lines = StringUtil.splitNewline(mappingText); // Example: // c android.support.a.b.a = C0005a // f android.support.a.b.a.a:Ljava/lang/Object; = f3a // m android.support.a.a.a.a(Landroid/app/Activity;[Ljava/lang/String;I)V = m0a int line = 0; for (String lineStr : lines) { line++; String[] args = lineStr.trim().split("[\\s=:]+"); String type = args[0]; try { switch (type) { case "c": // 1: class-name // 2: renamed class (does not include package) // Replace "." in class name String original = args[1].replace('.', '/'); String packageName = original.substring(0, original.lastIndexOf('/') + 1); // The new value is always in the same package. // Only the class is renamed, not the package. String renamed = packageName + args[2]; mappings.addClass(original, renamed); break; case "f": // 1: class-name.field-name // 2: field-type // 3: renamed String f1 = args[1].replaceAll("\\.(?=.+\\..+$)", "/"); String fieldOwner = f1.substring(0, f1.indexOf('.')); String fieldName = f1.substring(f1.indexOf('.') + 1); String fieldType = args[2]; String renamedField = args[3]; // Replace all "." except last one mappings.addField(fieldOwner, fieldType, fieldName, renamedField); break; case "m": // 1: class-name.method-name + method-desc // 2: renamed String m1 = args[1].replaceAll("\\.(?=.+\\..+$)", "/"); String methodOwner = m1.substring(0, m1.indexOf('.')); String methodName = m1.substring(m1.indexOf('.') + 1, m1.indexOf('(')); String methodType = m1.substring(m1.indexOf('(')); String renamedMethod = args[2]; // Replace all "." except last one mappings.addMethod(methodOwner, methodType, methodName, renamedMethod); break; default: break; } } catch (IndexOutOfBoundsException ex) { throw new IllegalArgumentException("Invalid jadx mappings, failed parsing line " + line, ex); } } return mappings; } @Override public String exportText(@Nonnull Mappings mappings) { StringBuilder sb = new StringBuilder(); IntermediateMappings intermediate = mappings.exportIntermediate(); for (String oldClassName : intermediate.getClassesWithMappings()) { ClassMapping classMapping = intermediate.getClassMapping(oldClassName); if (classMapping != null) { String newClassName = classMapping.getNewName(); // c android.support.a.b.a = C0005a sb.append("c ") .append(oldClassName.replace('/', '.')).append(" = ") .append(newClassName.substring(newClassName.lastIndexOf('/') + 1)).append("\n"); } for (FieldMapping fieldMapping : intermediate.getClassFieldMappings(oldClassName)) { String oldFieldName = fieldMapping.getOldName(); String newFieldName = fieldMapping.getNewName(); String fieldDesc = fieldMapping.getDesc(); // f android.support.a.b.a.a:Ljava/lang/Object; = f3a sb.append("f ") .append(oldClassName.replace('/', '.')).append('.') .append(oldFieldName).append(':').append(fieldDesc).append(" = ") .append(newFieldName).append("\n"); } for (MethodMapping methodMapping : intermediate.getClassMethodMappings(oldClassName)) { String oldMethodName = methodMapping.getOldName(); String newMethodName = methodMapping.getNewName(); String methodDesc = methodMapping.getDesc(); // m android.support.a.a.a.a(Landroid/app/Activity;[Ljava/lang/String;I)V = m0a sb.append("m ") .append(oldClassName.replace('/', '.')).append('.') .append(oldMethodName) .append(methodDesc).append(" = ") .append(newMethodName).append("\n"); } } return sb.toString(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/format/MappingFileFormat.java ================================================ package software.coley.recaf.services.mapping.format; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import net.fabricmc.mappingio.MappedElementKind; import net.fabricmc.mappingio.MappingVisitor; import net.fabricmc.mappingio.tree.MappingTree; import net.fabricmc.mappingio.tree.MemoryMappingTree; import net.fabricmc.mappingio.tree.VisitOrder; import software.coley.recaf.services.mapping.IntermediateMappings; import software.coley.recaf.services.mapping.Mappings; import software.coley.recaf.services.mapping.data.ClassMapping; import software.coley.recaf.services.mapping.data.FieldMapping; import software.coley.recaf.services.mapping.data.MethodMapping; import java.io.IOException; import java.io.StringReader; import java.io.StringWriter; import java.util.List; import java.util.function.Function; /** * Interface to use for explicit file format implementations of {@link Mappings}. *
*

Relevant noteworthy points

* Incomplete mappings: Not all mapping formats are complete in their representation. Some may omit the * descriptor of fields (Because at the source level, overloaded names are illegal within the same class). * So while the methods defined here will always be provided all of this information, each implementation may have to * do more than a flat one-to-one lookup in these cases. *

* Implementations do not need to be complete to partially work: Some mapping formats do not support renaming * for variable names in methods. This is fine, because any method in this interface can be implemented as a no-op by * returning {@code null}. * * @author Matt Coley */ public interface MappingFileFormat { /** * @return Name of the mapping format implementation. */ @Nonnull String implementationName(); /** * @param mappingsText * Text of the mappings to parse. * * @return Intermediate mappings from parsed text. * * @throws InvalidMappingException * When reading the mappings encounters any failure. */ @Nonnull IntermediateMappings parse(@Nonnull String mappingsText) throws InvalidMappingException; /** * Some mapping formats do not include field types since name overloading is illegal at the source level of Java. * It's valid in the bytecode but the mapping omits this info since it isn't necessary information for mapping * that does not support name overloading. * * @return {@code true} when field mappings include the type descriptor in their lookup information. */ boolean doesSupportFieldTypeDifferentiation(); /** * Some mapping forats do not include variable types since name overloading is illegal at the source level of Java. * Variable names are not used by the JVM at all so their names can be anything at the bytecode level. So including * the type makes it easier to reverse mappings. * * @return {@code true} when variable mappings include the type descriptor in their lookup information. */ boolean doesSupportVariableTypeDifferentiation(); /** * @return {@code true} when exporting the current mappings to text is supported. * * @see #exportText(Mappings) */ default boolean supportsExportText() { return true; } /** * @param mappings * Mappings to write with the current format. * * @return Exported mapping text in the current format. {@code null} if exporting to the format is unsupported. * * @throws InvalidMappingException * When writing the mappings encounters any failure. */ @Nullable default String exportText(@Nonnull Mappings mappings) throws InvalidMappingException { return null; } /** * A utility for utilizing mapping-io to parse mapping text formats. * * @param mappingText * Text of mapping to parse. * @param visitor * Visitor pointing to a mapping-io format reader. * * @return Intermediate mapping representation of the parsed text. * * @throws InvalidMappingException * When reading the mappings encounters any failure. */ @Nonnull static IntermediateMappings parse(@Nonnull String mappingText, @Nonnull MappingTreeReader visitor) throws InvalidMappingException { // Populate the mapping-io model MemoryMappingTree tree = new MemoryMappingTree(); StringReader reader = new StringReader(mappingText); try { visitor.read(reader, tree); } catch (IOException ex) { throw new InvalidMappingException(ex); } // Create our mapping model. IntermediateMappings mappings = new IntermediateMappings(); // Mapping IO supports multiple namespaces for outputs. // This is only really used in the 'tiny' format. Generally speaking the input columns look like: // obfuscated, intermediate, clean // or: // intermediate, clean // We want everything to map to the final column, rather than their notion of the first // column mapping to one of the following columns. int namespaceCount = tree.getDstNamespaces().size(); int finalNamespace = namespaceCount - 1; for (MappingTree.ClassMapping cm : tree.getClasses()) { String finalClassName = cm.getDstName(finalNamespace); if (finalClassName != null) { // Add the base case: input --> final output name mappings.addClass(cm.getSrcName(), finalClassName); // Add destination[n] --> final output name, where n < destinations.length - 1. // This is how we handle cases like 'intermediate --> clean' despite both of those // being "output" columns. if (namespaceCount > 1) for (int i = 0; i < finalNamespace; i++) { String intermediateClassName = cm.getDstName(i); if (intermediateClassName != null) mappings.addClass(intermediateClassName, finalClassName); } } for (MappingTree.FieldMapping fm : cm.getFields()) { String finalFieldName = fm.getDstName(finalNamespace); if (finalFieldName == null) continue; // Base case, like before. String fieldDesc = fm.getSrcDesc(); mappings.addField(cm.getSrcName(), fieldDesc, fm.getSrcName(), finalFieldName); // Support extra namespaces, like before. if (namespaceCount > 1) for (int i = 0; i < finalNamespace; i++) { String intermediateClassName = cm.getDstName(i); String intermediateFieldDesc = fm.getDstDesc(i); String intermediateFieldName = fm.getDstName(i); if (intermediateClassName != null && intermediateFieldName != null) mappings.addField(intermediateClassName, intermediateFieldDesc, intermediateFieldName, finalFieldName); } } for (MappingTree.MethodMapping mm : cm.getMethods()) { String finalMethodName = mm.getDstName(finalNamespace); if (finalMethodName == null) continue; // Base case, like before. String methodDesc = mm.getSrcDesc(); if (methodDesc != null) mappings.addMethod(cm.getSrcName(), methodDesc, mm.getSrcName(), finalMethodName); // Support extra namespaces, like before. if (namespaceCount > 1) for (int i = 0; i < finalNamespace; i++) { String intermediateClassName = cm.getDstName(i); String intermediateMethodDesc = mm.getDstDesc(i); String intermediateMethodName = mm.getDstName(i); if (intermediateClassName != null && intermediateMethodDesc != null && intermediateMethodName != null) mappings.addMethod(intermediateClassName, intermediateMethodDesc, intermediateMethodName, finalMethodName); } } } return mappings; } /** * A utility for utilizing mapping-io to write mapping text formats. * * @param mappings * Mappings to export to text. * @param writerFactory * Factory to create a mapping-io format writer. * * @return Text representation of mappings in the format provided by the writer factory. * * @throws InvalidMappingException * When writing the mappings encounters any failure. */ @Nonnull static String export(@Nonnull Mappings mappings, @Nonnull Function writerFactory) throws InvalidMappingException { return export(mappings, "in", List.of("out"), writerFactory); } /** * A utility for utilizing mapping-io to write mapping text formats. * * @param mappings * Mappings to export to text. * @param inputNamespace * Input column name. * @param outputNamespaces * Output column names. * @param writerFactory * Factory to create a mapping-io format writer. * * @return Text representation of mappings in the format provided by the writer factory. * * @throws InvalidMappingException * When writing the mappings encounters any failure. */ @Nonnull static String export(@Nonnull Mappings mappings, @Nonnull String inputNamespace, @Nonnull List outputNamespaces, @Nonnull Function writerFactory) throws InvalidMappingException { MemoryMappingTree tree = new MemoryMappingTree(); IntermediateMappings intermediate = mappings.exportIntermediate(); try { tree.visitNamespaces(inputNamespace, outputNamespaces); for (ClassMapping classMapping : intermediate.getClasses().values()) { String classOriginalName = classMapping.getOldName(); tree.visitClass(classOriginalName); tree.visitDstName(MappedElementKind.CLASS, 0, classMapping.getNewName()); List fieldMappings = intermediate.getClassFieldMappings(classOriginalName); for (FieldMapping fieldMapping : fieldMappings) { tree.visitField(fieldMapping.getOldName(), fieldMapping.getDesc()); tree.visitDstName(MappedElementKind.FIELD, 0, fieldMapping.getNewName()); } List methodMappings = intermediate.getClassMethodMappings(classOriginalName); for (MethodMapping methodMapping : methodMappings) { tree.visitMethod(methodMapping.getOldName(), methodMapping.getDesc()); tree.visitDstName(MappedElementKind.METHOD, 0, methodMapping.getNewName()); } } // Write the mappings in natural sorted order by name. // Intermediate mappings are *typically* sorted, but it is not a guarantee. VisitOrder order = VisitOrder.createByName(); StringWriter sw = new StringWriter(); MappingVisitor writer = writerFactory.apply(sw); tree.accept(writer, order); return sw.toString(); } catch (Throwable t) { throw new InvalidMappingException(t); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/format/MappingFormatManager.java ================================================ package software.coley.recaf.services.mapping.format; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; import software.coley.recaf.services.Service; import java.util.Map; import java.util.Set; import java.util.TreeMap; import java.util.function.Supplier; /** * Manager of supported {@link MappingFileFormat} implementations. * * @author Matt Coley */ @ApplicationScoped public class MappingFormatManager implements Service { public static final String SERVICE_ID = "mapping-formats"; private final Map> formatProviderMap = new TreeMap<>(); private final MappingFormatManagerConfig config; /** * @param config * Config to pull values from. * @param implementations * CDI provider of mapping format implementations. */ @Inject public MappingFormatManager(MappingFormatManagerConfig config, Instance implementations) { this.config = config; // Register implementations from CDI // The formats themselves are @Dependent meaning we get will use the handle's as suppliers for (Instance.Handle handle : implementations.handles()) { MappingFileFormat format = handle.get(); registerFormat(format.implementationName(), handle::get); } } /** * @return Set of all known file formats by name. */ @Nonnull public Set getMappingFileFormats() { return formatProviderMap.keySet(); } /** * @param name * Name of format. * * @return Instance of the file format, or {@code null} if none were found matching the name. */ @Nullable public MappingFileFormat createFormatInstance(String name) { Supplier supplier = formatProviderMap.get(name); if (supplier != null) return supplier.get(); return null; } /** * @param name * The format name. * @param supplier * A supplier to provide new instances of the format. */ public void registerFormat(@Nonnull String name, @Nonnull Supplier supplier) { formatProviderMap.put(name, supplier); } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public MappingFormatManagerConfig getServiceConfig() { return config; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/format/MappingFormatManagerConfig.java ================================================ package software.coley.recaf.services.mapping.format; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link MappingFormatManager}. * * @author Matt Coley */ @ApplicationScoped public class MappingFormatManagerConfig extends BasicConfigContainer implements ServiceConfig { @Inject public MappingFormatManagerConfig() { super(ConfigGroups.SERVICE_MAPPING, MappingFormatManager.SERVICE_ID + CONFIG_SUFFIX); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/format/MappingTreeReader.java ================================================ package software.coley.recaf.services.mapping.format; import jakarta.annotation.Nonnull; import net.fabricmc.mappingio.MappingVisitor; import net.fabricmc.mappingio.format.tiny.Tiny1FileReader; import java.io.IOException; import java.io.Reader; /** * Outlines the read process from a given reader into the given visitor. * Should point to a file-reader method from mapping-io. * For instance: {@link Tiny1FileReader#read(Reader, MappingVisitor)}. * * @author Matt Coley * @see MappingFileFormat#parse(String, MappingTreeReader) */ public interface MappingTreeReader { /** * @param reader * Reader containing the mapping file text. * @param visitor * Mapping output visitor. * * @throws IOException * When any mapping parse errors occur. */ void read(@Nonnull Reader reader, @Nonnull MappingVisitor visitor) throws IOException; } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/format/ProguardMappings.java ================================================ package software.coley.recaf.services.mapping.format; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.Dependent; import net.fabricmc.mappingio.format.proguard.ProGuardFileWriter; import software.coley.recaf.services.mapping.IntermediateMappings; import software.coley.recaf.services.mapping.Mappings; import software.coley.recaf.util.StringUtil; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; /** * Proguard mappings file implementation. * * @author xDark */ @Dependent public class ProguardMappings extends AbstractMappingFileFormat { public static final String NAME = "Proguard"; private static final String SPLITTER = " -> "; /** * New proguard instance. */ public ProguardMappings() { super(NAME, true, false); } @Nonnull @Override public IntermediateMappings parse(@Nonnull String mappingsText) { IntermediateMappings mappings = new IntermediateMappings(); List lines = Arrays.asList(StringUtil.splitNewline(mappingsText)); Map classMap = new HashMap<>(16384); StringBuilder firstCache = new StringBuilder(); StringBuilder secondCache = new StringBuilder(); { // Collect class mappings ProguardClassInfo classInfo = null; int definitionStart = -1; for (int i = 0, j = lines.size(); i < j; i++) { String line = lines.get(i); if (line.isEmpty() || line.trim().charAt(0) == '#') { continue; } int index = line.indexOf(SPLITTER); String left = line.substring(0, index); String right = line.substring(index + SPLITTER.length()); // Class mapping lines end with ':' if (right.charAt(right.length() - 1) == ':') { String originalClassName = left.replace('.', '/'); String obfuscatedName = right.substring(0, right.length() - 1).replace('.', '/'); mappings.addClass(obfuscatedName, originalClassName); if (classInfo != null) { // Record the lines that need to be processed for the prior classInfo entry // - These lines should include field/method mappings classInfo.toProcess = lines.subList(definitionStart + 1, i); } classInfo = new ProguardClassInfo(obfuscatedName); classMap.put(originalClassName, classInfo); definitionStart = i; } } // Handle case for recording lines for the last class in the mappings file if (classInfo != null) classInfo.toProcess = lines.subList(definitionStart + 1, lines.size()); } // Second pass for recording fields and methods for (ProguardClassInfo info : classMap.values()) { List toProcess = info.toProcess; for (String line : toProcess) { if (line.isEmpty() || line.trim().charAt(0) == '#') { continue; } int index = line.indexOf(SPLITTER); String left = line.substring(0, index); String right = line.substring(index + SPLITTER.length()); if (left.charAt(left.length() - 1) == ')') { int idx = left.indexOf(':'); if (idx != -1) { idx = left.indexOf(':', idx + 1); } String methodInfo = idx == -1 ? left : left.substring(idx + 1); int offset = 0; while (methodInfo.charAt(offset) == ' ') { offset++; } String returnType = denormalizeType(methodInfo.substring(offset, offset = methodInfo.indexOf(' ', offset)), firstCache, classMap); firstCache.setLength(0); firstCache.append('('); String methodName = methodInfo.substring(offset + 1, offset = methodInfo.indexOf('(')); int endOffset = methodInfo.indexOf(')', offset); parseDescriptor: { int typeStartOffset = methodInfo.indexOf(',', offset); if (typeStartOffset == -1) { if (endOffset == offset + 1) { break parseDescriptor; } } typeStartOffset = offset + 1; boolean anyLeft = true; do { int typeEndOfsset = methodInfo.indexOf(',', typeStartOffset); if (typeEndOfsset == -1) { anyLeft = false; typeEndOfsset = endOffset; } String type = denormalizeType(methodInfo.substring(typeStartOffset, typeEndOfsset), secondCache, classMap); firstCache.append(type); typeStartOffset = anyLeft ? methodInfo.indexOf(',', typeEndOfsset) + 1 : -1; } while (anyLeft); } firstCache.append(')').append(returnType); mappings.addMethod(info.mappedName, firstCache.toString(), right, methodName); } else { String fieldInfo = left; int offset = 0; while (fieldInfo.charAt(offset) == ' ') { offset++; } String fieldType = denormalizeType(fieldInfo.substring(offset, offset = fieldInfo.indexOf(' ', offset)), firstCache, classMap); String fieldName = fieldInfo.substring(offset + 1); mappings.addField(info.mappedName, fieldType, right, fieldName); } } } return mappings; } private static String denormalizeType(String type, StringBuilder stringCache, Map map) { int dimensions = 0; int offset = 1; int idx; while (type.charAt((idx = type.length() - offset)) == ']') { dimensions++; offset += 2; } stringCache.setLength(0); type = type.substring(0, idx + 1); switch (type) { case "void" -> type = "V"; case "long" -> type = "J"; case "double" -> type = "D"; case "int" -> type = "I"; case "float" -> type = "F"; case "char" -> type = "C"; case "short" -> type = "S"; case "byte" -> type = "B"; case "boolean" -> type = "Z"; default -> { type = type.replace('.', '/'); ProguardClassInfo classInfo = map.get(type); if (classInfo != null) { type = classInfo.mappedName; } stringCache.append('L').append(type).append(';'); } } if (dimensions != 0 || stringCache.length() != 0) { if (stringCache.length() == 0) { stringCache.append(type); } while (dimensions-- != 0) { stringCache.insert(0, '['); } type = stringCache.toString(); } return type; } @Nullable @Override public String exportText(@Nonnull Mappings mappings) throws InvalidMappingException { return MappingFileFormat.export(mappings, ProGuardFileWriter::new); } private static final class ProguardClassInfo { private List toProcess = List.of(); private final String mappedName; ProguardClassInfo(String mappedName) { this.mappedName = mappedName; } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/format/SimpleMappings.java ================================================ package software.coley.recaf.services.mapping.format; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import software.coley.recaf.services.mapping.IntermediateMappings; import software.coley.recaf.services.mapping.Mappings; import software.coley.recaf.services.mapping.data.ClassMapping; import software.coley.recaf.services.mapping.data.FieldMapping; import software.coley.recaf.services.mapping.data.MethodMapping; import software.coley.recaf.util.StringUtil; import java.util.Map; import static software.coley.recaf.util.EscapeUtil.escapeStandardAndUnicodeWhitespace; import static software.coley.recaf.util.EscapeUtil.unescapeStandardAndUnicodeWhitespace; /** * Simple mappings file implementation where the old/new names are split by a space. * The input format of the mappings is based on the format outlined by * {@link org.objectweb.asm.commons.SimpleRemapper#SimpleRemapper(int, Map)}. *
* Differences include: *
    *
  • Support for {@code #comment} lines
  • *
  • Support for unicode escape sequences ({@code \\uXXXX})
  • *
  • Support for fields specified by their name and descriptor
  • *
* * @author Matt Coley * @author Wolfie / win32kbase */ @Dependent public class SimpleMappings extends AbstractMappingFileFormat { public static final String NAME = "Simple"; /** * New simple instance. */ public SimpleMappings() { super(NAME, true, true); } @Nonnull @Override public IntermediateMappings parse(@Nonnull String mappingText) { IntermediateMappings mappings = new IntermediateMappings(); String[] lines = StringUtil.splitNewline(mappingText); // # Comment // BaseClass TargetClass // BaseClass.baseField targetField // BaseClass.baseField baseDesc targetField // BaseClass.baseMethod(BaseMethodDesc) targetMethod for (String line : lines) { // Skip comments and empty lines if (line.trim().startsWith("#") || line.trim().isEmpty()) continue; String[] args = line.split(" "); String oldBaseName = unescapeStandardAndUnicodeWhitespace(args[0]); if (args.length >= 3) { // Descriptor qualified field format String desc = unescapeStandardAndUnicodeWhitespace(args[1]); String targetName = unescapeStandardAndUnicodeWhitespace(args[2]); int dot = oldBaseName.lastIndexOf('.'); String oldClassName = oldBaseName.substring(0, dot); String oldFieldName = oldBaseName.substring(dot + 1); mappings.addField(oldClassName, desc, oldFieldName, targetName); } else { String newName = unescapeStandardAndUnicodeWhitespace(args[1]); int dot = oldBaseName.lastIndexOf('.'); if (dot > 0) { // Indicates a member String oldClassName = oldBaseName.substring(0, dot); String oldIdentifier = oldBaseName.substring(dot + 1); int methodDescStart = oldIdentifier.lastIndexOf("("); if (methodDescStart > 0) { // Method descriptor part of ID, split it up String methodName = oldIdentifier.substring(0, methodDescStart); String methodDesc = oldIdentifier.substring(methodDescStart); mappings.addMethod(oldClassName, methodDesc, methodName, newName); } else { // Likely a field without linked descriptor mappings.addField(oldClassName, null, oldIdentifier, newName); } } else { mappings.addClass(oldBaseName, newName); } } } return mappings; } @Override public String exportText(@Nonnull Mappings mappings) { StringBuilder sb = new StringBuilder(); IntermediateMappings intermediate = mappings.exportIntermediate(); for (String oldClassName : intermediate.getClassesWithMappings()) { ClassMapping classMapping = intermediate.getClassMapping(oldClassName); String escapedOldClassName = escapeStandardAndUnicodeWhitespace(oldClassName); if (classMapping != null) { String newClassName = classMapping.getNewName(); // BaseClass TargetClass sb.append(escapedOldClassName).append(' ').append(newClassName).append("\n"); } for (FieldMapping fieldMapping : intermediate.getClassFieldMappings(oldClassName)) { String oldFieldName = escapeStandardAndUnicodeWhitespace(fieldMapping.getOldName()); String newFieldName = escapeStandardAndUnicodeWhitespace(fieldMapping.getNewName()); String fieldDesc = escapeStandardAndUnicodeWhitespace(fieldMapping.getDesc()); if (fieldDesc != null) { // BaseClass.baseField baseDesc targetField sb.append(escapedOldClassName).append('.').append(oldFieldName) .append(' ').append(fieldDesc) .append(' ').append(newFieldName).append("\n"); } else { // BaseClass.baseField targetField sb.append(escapedOldClassName).append('.').append(oldFieldName) .append(' ').append(newFieldName).append("\n"); } } for (MethodMapping methodMapping : intermediate.getClassMethodMappings(oldClassName)) { String oldMethodName = escapeStandardAndUnicodeWhitespace(methodMapping.getOldName()); String newMethodName = escapeStandardAndUnicodeWhitespace(methodMapping.getNewName()); String methodDesc = escapeStandardAndUnicodeWhitespace(methodMapping.getDesc()); // BaseClass.baseMethod(BaseMethodDesc) targetMethod sb.append(escapedOldClassName).append('.').append(oldMethodName) .append(methodDesc) .append(' ').append(newMethodName).append("\n"); } } return sb.toString(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/format/SrgMappings.java ================================================ package software.coley.recaf.services.mapping.format; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.Dependent; import org.objectweb.asm.commons.Remapper; import org.slf4j.Logger; import software.coley.collections.tuple.Pair; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.services.mapping.BasicMappingsRemapper; import software.coley.recaf.services.mapping.IntermediateMappings; import software.coley.recaf.services.mapping.Mappings; import software.coley.recaf.services.mapping.data.ClassMapping; import software.coley.recaf.services.mapping.data.FieldMapping; import software.coley.recaf.services.mapping.data.MethodMapping; import software.coley.recaf.util.StringUtil; import java.util.ArrayList; import java.util.List; /** * The MCP SRG format. * * @author Matt Coley */ @Dependent public class SrgMappings extends AbstractMappingFileFormat { public static final String NAME = "SRG"; private final Logger logger = Logging.get(TinyV1Mappings.class); /** * New SRG instance. */ public SrgMappings() { super(NAME, false, false); } @Nonnull @Override public IntermediateMappings parse(@Nonnull String mappingText) { List> packages = new ArrayList<>(); IntermediateMappings mappings = new SrgIntermediateMappings(packages); String[] lines = StringUtil.splitNewline(mappingText); int line = 0; for (String lineStr : lines) { line++; String[] args = lineStr.trim().split(" "); String type = args[0]; try { switch (type) { case "PK:" -> { String obfPackage = args[1]; String renamedPackage = args[2]; packages.add(new Pair<>(obfPackage, renamedPackage)); } case "CL:" -> { String obfClass = args[1]; String renamedClass = args[2]; mappings.addClass(obfClass, renamedClass); } case "FD:" -> { // Common format: // 0 1 // FD obf-owner/obf-name String obfKey = args[1]; int splitPos = obfKey.lastIndexOf('/'); String obfOwner = obfKey.substring(0, splitPos); String obfName = obfKey.substring(splitPos + 1); // Handle SRG variants if (args.length == 5) { // XSRG format: // 0 1 2 3 4 // FD obf-owner/obf-name obf-desc clean-owner/clean-name clean-desc String obfDesc = args[2]; String renamedKey = args[3]; splitPos = renamedKey.lastIndexOf('/'); String renamedName = renamedKey.substring(splitPos + 1); mappings.addField(obfOwner, obfDesc, obfName, renamedName); } else { // SRG format: // FD obf-owner/obf-name clean-owner/clean-name String renamedKey = args[2]; splitPos = renamedKey.lastIndexOf('/'); String renamedName = renamedKey.substring(splitPos + 1); mappings.addField(obfOwner, null, obfName, renamedName); } } case "MD:" -> { // Common format: // 0 1 3 // MD obf-owner/obf-name obf-desc String obfKey = args[1]; int splitPos = obfKey.lastIndexOf('/'); String obfOwner = obfKey.substring(0, splitPos); String obfName = obfKey.substring(splitPos + 1); String obfDesc = args[2]; // Handle SRG variants if (args.length == 5) { // XSRG format: // 0 1 2 3 4 // MD obf-owner/obf-name obf-desc clean-owner/clean-name clean-desc String renamedKey = args[3]; splitPos = renamedKey.lastIndexOf('/'); String renamedName = renamedKey.substring(splitPos + 1); mappings.addMethod(obfOwner, obfDesc, obfName, renamedName); } else { // SRG format: // 0 1 2 3 // MD obf-owner/obf-name obf-desc clean-owner/clean-name String renamedKey = args[3]; splitPos = renamedKey.lastIndexOf('/'); String renamedName = renamedKey.substring(splitPos + 1); mappings.addMethod(obfOwner, obfDesc, obfName, renamedName); } } default -> logger.trace("Unknown SRG mappings line type: \"{}\" @line {}", type, line); } } catch (IndexOutOfBoundsException ex) { throw new IllegalArgumentException("Failed parsing line " + line, ex); } } return mappings; } @Override public String exportText(@Nonnull Mappings mappings) { StringBuilder sb = new StringBuilder(); Remapper remapper = new BasicMappingsRemapper(mappings); IntermediateMappings intermediate = mappings.exportIntermediate(); for (String oldClassName : intermediate.getClassesWithMappings()) { ClassMapping classMapping = intermediate.getClassMapping(oldClassName); if (classMapping != null) { String newClassName = classMapping.getNewName(); // CL: BaseClass TargetClass sb.append("CL: ").append(oldClassName).append(' ') .append(newClassName).append("\n"); } String newClassName = classMapping == null ? oldClassName : classMapping.getNewName(); for (FieldMapping fieldMapping : intermediate.getClassFieldMappings(oldClassName)) { String oldFieldName = fieldMapping.getOldName(); String newFieldName = fieldMapping.getNewName(); // FD: BaseClass/baseField TargetClass/targetField sb.append("FD: ") .append(oldClassName).append('/').append(oldFieldName) .append(' ') .append(newClassName).append('/').append(newFieldName).append("\n"); } for (MethodMapping methodMapping : intermediate.getClassMethodMappings(oldClassName)) { String oldMethodName = methodMapping.getOldName(); String newMethodName = methodMapping.getNewName(); String methodDesc = methodMapping.getDesc(); String mappedDesc = remapper.mapDesc(methodDesc); // MD: BaseClass/baseMethod baseDesc TargetClass/targetMethod targetDesc sb.append("MD: ") .append(oldClassName).append('/').append(oldMethodName) .append(' ') .append(methodDesc) .append(' ') .append(newClassName).append('/').append(newMethodName) .append(' ') .append(mappedDesc).append('\n'); } } return sb.toString(); } /** * Extension of intermediate mappings to support {@code PK} entries in the mapping file. */ private static class SrgIntermediateMappings extends IntermediateMappings { private final List> packageMappings; public SrgIntermediateMappings(List> packageMappings) { super(); this.packageMappings = packageMappings; } @Override public boolean doesSupportFieldTypeDifferentiation() { // SRG fields do not include type info. return false; } @Override public boolean doesSupportVariableTypeDifferentiation() { // See above. return false; } @Nullable @Override public ClassMapping getClassMapping(@Nonnull String name) { ClassMapping classMapping = super.getClassMapping(name); if (classMapping == null && !packageMappings.isEmpty()) { for (Pair packageMapping : packageMappings) { String oldPackage = packageMapping.getLeft(); if (name.startsWith(oldPackage)) { String newPackage = packageMapping.getRight(); return new ClassMapping(name, newPackage + name.substring(oldPackage.length())); } } } return classMapping; } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/format/TinyV1Mappings.java ================================================ package software.coley.recaf.services.mapping.format; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import net.fabricmc.mappingio.format.tiny.Tiny1FileReader; import net.fabricmc.mappingio.format.tiny.Tiny1FileWriter; import software.coley.recaf.services.mapping.IntermediateMappings; import software.coley.recaf.services.mapping.Mappings; import java.util.List; /** * Tiny-V1 mappings file implementation. * * @author Matt Coley * @author Wolfie / win32kbase */ @Dependent public class TinyV1Mappings extends AbstractMappingFileFormat { public static final String NAME = "Tiny-V1"; /** * New tiny v1 instance. */ public TinyV1Mappings() { super(NAME, true, true); } @Nonnull @Override public IntermediateMappings parse(@Nonnull String mappingText) throws InvalidMappingException { return MappingFileFormat.parse(mappingText, Tiny1FileReader::read); } @Override public String exportText(@Nonnull Mappings mappings) throws InvalidMappingException { return MappingFileFormat.export(mappings, "intermediary", List.of("named"), Tiny1FileWriter::new); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/format/TinyV2Mappings.java ================================================ package software.coley.recaf.services.mapping.format; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import net.fabricmc.mappingio.format.tiny.Tiny2FileReader; import net.fabricmc.mappingio.format.tiny.Tiny2FileWriter; import software.coley.recaf.services.mapping.IntermediateMappings; import software.coley.recaf.services.mapping.Mappings; import java.util.List; /** * Tiny-V2 mappings file implementation. * * @author Matt Coley */ @Dependent public class TinyV2Mappings extends AbstractMappingFileFormat { public static final String NAME = "Tiny-V2"; /** * New tiny v2 instance. */ public TinyV2Mappings() { super(NAME, true, true); } @Nonnull @Override public IntermediateMappings parse(@Nonnull String mappingText) throws InvalidMappingException { return MappingFileFormat.parse(mappingText, Tiny2FileReader::read); } @Override public String exportText(@Nonnull Mappings mappings) throws InvalidMappingException { return MappingFileFormat.export(mappings, "intermediary", List.of("named"), writer -> new Tiny2FileWriter(writer, true)); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/MappingGenerator.java ================================================ package software.coley.recaf.services.mapping.gen; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.ClassMember; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.services.Service; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.services.inheritance.InheritanceVertex; import software.coley.recaf.services.mapping.Mappings; import software.coley.recaf.services.mapping.MappingsAdapter; import software.coley.recaf.services.mapping.gen.filter.ExcludeEnumMethodsFilter; import software.coley.recaf.services.mapping.gen.filter.NameGeneratorFilter; import software.coley.recaf.services.mapping.gen.naming.NameGenerator; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.Bundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.SortedMap; import java.util.TreeMap; /** * Mapping generator. * * @author Matt Coley */ @ApplicationScoped public class MappingGenerator implements Service { public static final String SERVICE_ID = "mapping-generator"; private final MappingGeneratorConfig config; @Inject public MappingGenerator(@Nonnull MappingGeneratorConfig config) { this.config = config; } /** * @param workspace * Workspace to pull class information from. * Can be {@code null} but some assumptions will be made about inner-class names. * @param resource * Resource to generate mappings for. * @param inheritanceGraph * Inheritance graph to determine class hierarchies. * @param generator * Name generation implementation. * @param filter * Name generation filter, used to limit which classes and members get renamed. * * @return Newly generated mappings. */ @Nonnull public Mappings generate(@Nullable Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull InheritanceGraph inheritanceGraph, @Nonnull NameGenerator generator, @Nullable NameGeneratorFilter filter) { // Adapt filter to handle baseline cases. filter = new ExcludeEnumMethodsFilter(filter); // Setup adapter to store our mappings in. MappingsAdapter mappings = new MappingsAdapter(true, true); mappings.enableHierarchyLookup(inheritanceGraph); if (workspace != null) mappings.enableClassLookup(workspace); SortedMap classMap = new TreeMap<>(); resource.jvmAllClassBundleStreamRecursive() .flatMap(Bundle::stream) .forEach(c -> classMap.putIfAbsent(c.getName(), c)); // Pull a class, create mappings for its inheritance family, then remove those classes from the map. // When the map is empty everything has been run through the mapping generation process. while (!classMap.isEmpty()) { // Get family from the class. String className = classMap.firstKey(); Set family = inheritanceGraph.getVertexFamily(className, false); // Create mappings for the family generateFamilyMappings(mappings, family, generator, filter); // Remove all family members from the class map. if (family.isEmpty()) classMap.remove(className); else family.forEach(vertex -> classMap.remove(vertex.getName())); } return mappings; } private void generateFamilyMappings(@Nonnull MappingsAdapter mappings, @Nonnull Set family, @Nonnull NameGenerator generator, @Nonnull NameGeneratorFilter filter) { // Collect the members in the family that are inheritable, and methods that are library implementations. // We want this information so that for these members we give them a single name throughout the family. // - Methods can be indirectly linked by two interfaces describing the same signature, // and a child type implementing both types. So we have to be strict with naming with cases like this. // - Fields do not have such a concern, but can still be accessed by child type owners. Set inheritableFields = new HashSet<>(); Set inheritableMethods = new HashSet<>(); Set libraryMethods = new HashSet<>(); family.forEach(vertex -> { // Skip module-info classes if (vertex.isModule()) return; // Record fields/methods, skipping private items since they cannot span the hierarchy. for (FieldMember field : vertex.getValue().getFields()) { if (field.hasPrivateModifier()) continue; inheritableFields.add(MemberKey.of(field)); } for (MethodMember method : vertex.getValue().getMethods()) { if (method.hasPrivateModifier()) continue; MemberKey key = MemberKey.of(method); inheritableMethods.add(key); // Need to track which methods we cannot remap due to them being overrides of libraries // rather than being declared solely in our resource. if (vertex.isLibraryMethod(method.getName(), method.getDescriptor())) libraryMethods.add(key); } }); // Create mappings for members. family.forEach(vertex -> { // Skip libraries in the family. if (vertex.isLibraryVertex()) return; // Skip module-info classes if (vertex.isModule()) return; ClassInfo owner = vertex.getValue(); String ownerName = owner.getName(); for (FieldMember field : owner.getFields()) { String fieldName = field.getName(); String fieldDesc = field.getDescriptor(); // Skip if filtered. if (!filter.shouldMapField(owner, field)) continue; // Skip if already mapped. if (mappings.getMappedFieldName(ownerName, fieldName, fieldDesc) != null) continue; // Create mapped name and record into mappings. MemberKey key = MemberKey.of(field); String mappedFieldName = generator.mapField(owner, field); if (inheritableFields.contains(key)) { // Field is 'inheritable' meaning it needs to have a consistent name // for all children and parents of this vertex. Set targetFamilyMembers = new HashSet<>(); targetFamilyMembers.add(vertex); targetFamilyMembers.addAll(vertex.getAllChildren()); targetFamilyMembers.addAll(vertex.getAllParents()); targetFamilyMembers.forEach(immediateTreeVertex -> { if (immediateTreeVertex.hasField(fieldName, fieldDesc)) { String treeOwner = immediateTreeVertex.getName(); mappings.addField(treeOwner, fieldName, fieldDesc, mappedFieldName); } }); } else { // Not 'inheritable' so an independent mapping is all we need. mappings.addField(ownerName, fieldName, fieldDesc, mappedFieldName); } } for (MethodMember method : owner.getMethods()) { String methodName = method.getName(); String methodDesc = method.getDescriptor(); // Skip if reserved method name. if (!methodName.isEmpty() && methodName.charAt(0) == '<') continue; // Skip if filtered. if (!filter.shouldMapMethod(owner, method)) continue; // Skip if method is a library method, or is already mapped. MemberKey key = MemberKey.of(method); if (libraryMethods.contains(key) || mappings.getMappedMethodName(ownerName, methodName, methodDesc) != null) continue; // Create variable mappings for (LocalVariable variable : method.getLocalVariables()) { String variableName = variable.getName(); // Do not rename 'this' local variable... Unless its not "this" then force it to be "this" if (variable.getIndex() == 0 && !method.hasStaticModifier()) { if (!"this".equals(variableName)) mappings.addVariable(ownerName, methodName, methodDesc, variableName, variable.getDescriptor(), variable.getIndex(), "this"); continue; } if (filter.shouldMapLocalVariable(owner, method, variable)) { String mappedVariableName = generator.mapVariable(owner, method, variable); if (!mappedVariableName.equals(variableName)) { mappings.addVariable(ownerName, methodName, methodDesc, variableName, variable.getDescriptor(), variable.getIndex(), mappedVariableName); } } } // Create mapped name and record into mappings. String mappedMethodName = generator.mapMethod(owner, method); // Skip if the name generator yields the same name back. if (methodName.equals(mappedMethodName)) continue; if (inheritableMethods.contains(key)) { // Method is 'inheritable' meaning it needs to have a consistent name for the entire family. // But if one of the members of the family is filtered, then we cannot map anything. boolean shouldMapFamily = true; List pendingMapAdditions = new ArrayList<>(); for (InheritanceVertex familyVertex : family) { if (familyVertex.hasMethod(methodName, methodDesc)) { if (filter.shouldMapMethod(familyVertex.getValue(), method)) { pendingMapAdditions.add(() -> mappings.addMethod(familyVertex.getName(), methodName, methodDesc, mappedMethodName)); } else { shouldMapFamily = false; pendingMapAdditions.clear(); break; } } } // Nothing in the family was filtered, we can add the method mappings. if (shouldMapFamily) pendingMapAdditions.forEach(Runnable::run); } else { // Not 'inheritable' so an independent mapping is all we need. mappings.addMethod(ownerName, methodName, methodDesc, mappedMethodName); } } }); // Create mappings for classes. family.forEach(vertex -> { // Skip libraries in the family. if (vertex.isLibraryVertex()) return; // Skip module-info classes if (vertex.isModule()) return; // Skip if filtered. ClassInfo classInfo = vertex.getValue(); if (!filter.shouldMapClass(classInfo)) return; // Add mapping. String name = vertex.getName(); String mapped = generator.mapClass(classInfo); mappings.addClass(name, mapped); }); } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public MappingGeneratorConfig getServiceConfig() { return config; } /** * Local record to use as set entries that are simpler than {@link ClassMember} implementations. *

* Most importantly the {@link Object#hashCode()} of this type is based only on the name and descriptor. * This ensures additional data like local variable or generic signature data doesn't interfere with operations * such as {@link #generateFamilyMappings(MappingsAdapter, Set, NameGenerator, NameGeneratorFilter)}. * * @param name * Field/method name. * @param descriptor * Field/method descriptor. */ private record MemberKey(@Nonnull String name, @Nonnull String descriptor) { @Nonnull static MemberKey of(@Nonnull ClassMember member) { return new MemberKey(member.getName(), member.getDescriptor()); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/MappingGeneratorConfig.java ================================================ package software.coley.recaf.services.mapping.gen; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link MappingGenerator}. * * @author Matt Coley */ @ApplicationScoped public class MappingGeneratorConfig extends BasicConfigContainer implements ServiceConfig { @Inject public MappingGeneratorConfig() { super(ConfigGroups.SERVICE_MAPPING, MappingGenerator.SERVICE_ID + CONFIG_SUFFIX); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/filter/ExcludeClassesFilter.java ================================================ package software.coley.recaf.services.mapping.gen.filter; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.services.search.match.StringPredicate; /** * Filter that excludes classes (and their members). * * @author Matt Coley * @see IncludeClassesFilter */ public class ExcludeClassesFilter extends NameGeneratorFilter { private final StringPredicate namePredicate; /** * @param next * Next filter to link. Chaining filters allows for {@code thisFilter && nextFilter}. * @param namePredicate * Class name predicate for excluded names. */ public ExcludeClassesFilter(@Nullable NameGeneratorFilter next, @Nonnull StringPredicate namePredicate) { super(next, true); this.namePredicate = namePredicate; } @Override public boolean shouldMapClass(@Nonnull ClassInfo info) { return super.shouldMapClass(info) && !(namePredicate.match(info.getName())); } @Override public boolean shouldMapField(@Nonnull ClassInfo owner, @Nonnull FieldMember field) { // Consider owner type, we do not want to map fields if they are inside the exclusion filter return shouldMapClass(owner) && super.shouldMapField(owner, field); } @Override public boolean shouldMapMethod(@Nonnull ClassInfo owner, @Nonnull MethodMember method) { // Consider owner type, we do not want to map methods if they are inside the exclusion filter return shouldMapClass(owner) && super.shouldMapMethod(owner, method); } @Override public boolean shouldMapLocalVariable(@Nonnull ClassInfo owner, @Nonnull MethodMember declaringMethod, @Nonnull LocalVariable variable) { return shouldMapClass(owner) && super.shouldMapMethod(owner, declaringMethod) && super.shouldMapLocalVariable(owner, declaringMethod, variable); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/filter/ExcludeEnumMethodsFilter.java ================================================ package software.coley.recaf.services.mapping.gen.filter; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.MethodMember; /** * Filter to prevent renaming of {@code Enum.values()} and {@code Enum.valueOf(String)} implementations. * * @author Matt Coley */ public class ExcludeEnumMethodsFilter extends NameGeneratorFilter { /** * @param next * Next filter to link. Chaining filters allows for {@code thisFilter && nextFilter}. */ public ExcludeEnumMethodsFilter(@Nullable NameGeneratorFilter next) { super(next, true); } @Override public boolean shouldMapMethod(@Nonnull ClassInfo owner, @Nonnull MethodMember method) { if (owner.hasEnumModifier()) { String ownerName = owner.getName(); String name = method.getName(); String desc = method.getDescriptor(); if (name.equals("values") && desc.equals("()[L" + ownerName + ";")) { return false; } else if (name.equals("valueOf") && desc.equals("(Ljava/lang/String;)L" + ownerName + ";")) { return false; } } return super.shouldMapMethod(owner, method); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/filter/ExcludeExistingMappedFilter.java ================================================ package software.coley.recaf.services.mapping.gen.filter; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.services.mapping.aggregate.AggregatedMappings; /** * Filter that excludes names that have already been specified by {@link AggregatedMappings}. * * @author Matt Coley */ public class ExcludeExistingMappedFilter extends NameGeneratorFilter { private final AggregatedMappings aggregate; /** * @param next * Next filter to link. Chaining filters allows for {@code thisFilter && nextFilter}. * @param aggregate * Aggregate mappings instance to use when checking for existing mapping entries. */ public ExcludeExistingMappedFilter(@Nullable NameGeneratorFilter next, @Nonnull AggregatedMappings aggregate) { super(next, true); this.aggregate = aggregate; } @Override public boolean shouldMapClass(@Nonnull ClassInfo info) { if (aggregate.getReverseClassMapping(info.getName()) != null) return false; return super.shouldMapClass(info); } @Override public boolean shouldMapField(@Nonnull ClassInfo owner, @Nonnull FieldMember field) { if (aggregate.getReverseFieldMapping(owner.getName(), field.getName(), field.getDescriptor()) != null) return false; return super.shouldMapField(owner, field); } @Override public boolean shouldMapMethod(@Nonnull ClassInfo owner, @Nonnull MethodMember method) { if (aggregate.getReverseMethodMapping(owner.getName(), method.getName(), method.getDescriptor()) != null) return false; return super.shouldMapMethod(owner, method); } @Override public boolean shouldMapLocalVariable(@Nonnull ClassInfo owner, @Nonnull MethodMember declaringMethod, @Nonnull LocalVariable variable) { if (aggregate.getReverseVariableMapping(owner.getName(), declaringMethod.getName(), declaringMethod.getDescriptor(), variable.getName(), variable.getDescriptor(), variable.getIndex()) != null) return false; return super.shouldMapLocalVariable(owner, declaringMethod, variable); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/filter/ExcludeModifiersNameFilter.java ================================================ package software.coley.recaf.services.mapping.gen.filter; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import java.util.Collection; /** * Filter that excludes classes and members that match the given access modifiers. * * @author Matt Coley * @see IncludeModifiersNameFilter */ public class ExcludeModifiersNameFilter extends NameGeneratorFilter { private final int[] flags; private final boolean targetClasses; private final boolean targetFields; private final boolean targetMethods; /** * @param next * Next filter to link. Chaining filters allows for {@code thisFilter && nextFilter}. * @param flags * Access flags to check for. * @param targetClasses * Check against classes. * @param targetFields * Check against fields. * @param targetMethods * Check against methods. */ public ExcludeModifiersNameFilter(@Nullable NameGeneratorFilter next, @Nonnull Collection flags, boolean targetClasses, boolean targetFields, boolean targetMethods) { super(next, true); this.flags = flags.stream().mapToInt(i -> i).toArray(); this.targetClasses = targetClasses; this.targetFields = targetFields; this.targetMethods = targetMethods; } @Override public boolean shouldMapClass(@Nonnull ClassInfo info) { if (targetClasses && info.hasAnyModifiers(flags)) return false; return super.shouldMapClass(info); } @Override public boolean shouldMapField(@Nonnull ClassInfo owner, @Nonnull FieldMember field) { if (targetFields && field.hasAnyModifiers(flags)) return false; return super.shouldMapField(owner, field); } @Override public boolean shouldMapMethod(@Nonnull ClassInfo owner, @Nonnull MethodMember method) { if (targetMethods && method.hasAnyModifiers(flags)) return false; return super.shouldMapMethod(owner, method); } @Override public boolean shouldMapLocalVariable(@Nonnull ClassInfo owner, @Nonnull MethodMember declaringMethod, @Nonnull LocalVariable variable) { // Variables are not targeted, so delegate to next filter return super.shouldMapLocalVariable(owner, declaringMethod, variable); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/filter/ExcludeNameFilter.java ================================================ package software.coley.recaf.services.mapping.gen.filter; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.services.search.match.StringPredicate; /** * Filter that excludes classes, fields, methods, and variables by their names. * * @author Matt Coley * @see IncludeNameFilter */ public class ExcludeNameFilter extends NameGeneratorFilter { private final StringPredicate classPredicate; private final StringPredicate fieldPredicate; private final StringPredicate methodPredicate; private final StringPredicate variablePredicate; /** * @param next * Next filter to link. Chaining filters allows for {@code thisFilter && nextFilter}. * @param classPredicate * Class name predicate for included names. * {@code null} to skip filtering for class names. * @param fieldPredicate * Field name predicate for included names. * {@code null} to skip filtering for field names. * @param methodPredicate * Method name predicate for included names. * {@code null} to skip filtering for method names. * @param variablePredicate * Variable name predicate for included names. * {@code null} to skip filtering for variable names. */ public ExcludeNameFilter(@Nullable NameGeneratorFilter next, @Nullable StringPredicate classPredicate, @Nullable StringPredicate fieldPredicate, @Nullable StringPredicate methodPredicate, @Nullable StringPredicate variablePredicate) { super(next, true); this.classPredicate = classPredicate; this.fieldPredicate = fieldPredicate; this.methodPredicate = methodPredicate; this.variablePredicate = variablePredicate; } @Override public boolean shouldMapClass(@Nonnull ClassInfo info) { return super.shouldMapClass(info) && !(classPredicate != null && classPredicate.match(info.getName())); } @Override public boolean shouldMapField(@Nonnull ClassInfo owner, @Nonnull FieldMember field) { return super.shouldMapField(owner, field) && !(fieldPredicate != null && fieldPredicate.match(field.getName())); } @Override public boolean shouldMapMethod(@Nonnull ClassInfo owner, @Nonnull MethodMember method) { return super.shouldMapMethod(owner, method) && !(methodPredicate != null && methodPredicate.match(method.getName())); } @Override public boolean shouldMapLocalVariable(@Nonnull ClassInfo owner, @Nonnull MethodMember declaringMethod, @Nonnull LocalVariable variable) { return super.shouldMapLocalVariable(owner, declaringMethod, variable) && !(variablePredicate != null && variablePredicate.match(variable.getName())); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/filter/IncludeClassesFilter.java ================================================ package software.coley.recaf.services.mapping.gen.filter; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.services.search.match.StringPredicate; /** * Filter that includes classes (and their members). * * @author Matt Coley * @see ExcludeClassesFilter */ public class IncludeClassesFilter extends NameGeneratorFilter { private final StringPredicate namePredicate; /** * @param next * Next filter to link. Chaining filters allows for {@code thisFilter && nextFilter}. * @param namePredicate * Class name predicate for included names. */ public IncludeClassesFilter(@Nullable NameGeneratorFilter next, @Nonnull StringPredicate namePredicate) { super(next, true); this.namePredicate = namePredicate; } @Override public boolean shouldMapClass(@Nonnull ClassInfo info) { return super.shouldMapClass(info) && (namePredicate.match(info.getName())); } @Override public boolean shouldMapField(@Nonnull ClassInfo owner, @Nonnull FieldMember field) { // Consider owner type, we do not want to map fields if they are outside the inclusion filter return shouldMapClass(owner) && super.shouldMapField(owner, field); } @Override public boolean shouldMapMethod(@Nonnull ClassInfo owner, @Nonnull MethodMember method) { // Consider owner type, we do not want to map methods if they are outside the inclusion filter return shouldMapClass(owner) && super.shouldMapMethod(owner, method); } @Override public boolean shouldMapLocalVariable(@Nonnull ClassInfo owner, @Nonnull MethodMember declaringMethod, @Nonnull LocalVariable variable) { // Consider owner type and method, we do not want to map variables if they are outside the inclusion filter return shouldMapClass(owner) && super.shouldMapMethod(owner, declaringMethod) && super.shouldMapLocalVariable(owner, declaringMethod, variable); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/filter/IncludeKeywordNameFilter.java ================================================ package software.coley.recaf.services.mapping.gen.filter; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.util.StringUtil; import java.util.List; import java.util.Set; import static software.coley.recaf.util.Keywords.getKeywords; /** * Filter that includes names that contain (when split by boundary characters) reserved Java keywords. * * @author Matt Coley */ public class IncludeKeywordNameFilter extends NameGeneratorFilter { private static final Set methodExemptions = Set.of("record"); /** * @param next * Next filter to link. Chaining filters allows for {@code thisFilter && nextFilter}. */ public IncludeKeywordNameFilter(@Nullable NameGeneratorFilter next) { super(next, false); } @Override public boolean shouldMapClass(@Nonnull ClassInfo info) { String name = info.getName(); if (containsKeyword(name)) return true; return super.shouldMapClass(info); } @Override public boolean shouldMapField(@Nonnull ClassInfo owner, @Nonnull FieldMember info) { if (containsKeyword(info.getName())) return true; return super.shouldMapField(owner, info); } @Override public boolean shouldMapMethod(@Nonnull ClassInfo owner, @Nonnull MethodMember info) { String name = info.getName(); if (containsKeyword(name) && !methodExemptions.contains(name)) return true; return super.shouldMapMethod(owner, info); } @Override public boolean shouldMapLocalVariable(@Nonnull ClassInfo owner, @Nonnull MethodMember declaringMethod, @Nonnull LocalVariable variable) { // Edge case: 'this' is allowed only as local variable slot 0 on non-static methods. if (!declaringMethod.hasStaticModifier() && variable.getIndex() == 0 && "this".equals(variable.getName())) return false; if (containsKeyword(variable.getName())) return true; return super.shouldMapLocalVariable(owner, declaringMethod, variable); } private static boolean containsKeyword(@Nonnull String name) { Set keywords = getKeywords(); String filtered = name.indexOf('-') > 0 ? name.replace("package-info", "package_info").replace("module-info", "module_info") : name; List parts = StringUtil.fastSplitNonIdentifier(filtered); for (String part : parts) { if (keywords.contains(part)) return true; } return false; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/filter/IncludeLongNameFilter.java ================================================ package software.coley.recaf.services.mapping.gen.filter; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.ClassMember; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; /** * Filter that includes names that are longer than a given size. * * @author Matt Coley */ public class IncludeLongNameFilter extends NameGeneratorFilter { private final int maxNameLength; private final boolean targetClasses; private final boolean targetFields; private final boolean targetMethods; private final boolean targetVariables; /** * @param next * Next filter to link. Chaining filters allows for {@code thisFilter && nextFilter}. * @param maxNameLength * Max length of names allowed. * @param targetClasses * Check against classes. * @param targetFields * Check against fields. * @param targetMethods * Check against methods. * @param targetVariables * Check against variables. */ public IncludeLongNameFilter(@Nullable NameGeneratorFilter next, int maxNameLength, boolean targetClasses, boolean targetFields, boolean targetMethods, boolean targetVariables) { super(next, false); this.maxNameLength = maxNameLength; this.targetClasses = targetClasses; this.targetFields = targetFields; this.targetMethods = targetMethods; this.targetVariables = targetVariables; } @Override public boolean shouldMapClass(@Nonnull ClassInfo info) { if (targetClasses && shouldMap(info)) return true; return super.shouldMapClass(info); } @Override public boolean shouldMapField(@Nonnull ClassInfo owner, @Nonnull FieldMember field) { if (targetFields && shouldMap(field)) return true; return super.shouldMapField(owner, field); } @Override public boolean shouldMapMethod(@Nonnull ClassInfo owner, @Nonnull MethodMember method) { if (targetMethods && shouldMap(method)) return true; return super.shouldMapMethod(owner, method); } @Override public boolean shouldMapLocalVariable(@Nonnull ClassInfo owner, @Nonnull MethodMember declaringMethod, @Nonnull LocalVariable variable) { if (targetVariables && shouldMap(variable)) return true; return super.shouldMapLocalVariable(owner, declaringMethod, variable); } private boolean shouldMap(ClassInfo info) { return shouldMap(info.getName()); } private boolean shouldMap(ClassMember info) { return shouldMap(info.getName()); } private boolean shouldMap(LocalVariable info) { return shouldMap(info.getName()); } private boolean shouldMap(String name) { return name.length() > maxNameLength; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/filter/IncludeModifiersNameFilter.java ================================================ package software.coley.recaf.services.mapping.gen.filter; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import java.util.Collection; /** * Filter that includes classes and members that match the given access modifiers. * * @author Matt Coley * @see ExcludeModifiersNameFilter */ public class IncludeModifiersNameFilter extends NameGeneratorFilter { private final int[] flags; private final boolean targetClasses; private final boolean targetFields; private final boolean targetMethods; /** * @param next * Next filter to link. Chaining filters allows for {@code thisFilter && nextFilter}. * @param flags * Access flags to check for. * @param targetClasses * Check against classes. * @param targetFields * Check against fields. * @param targetMethods * Check against methods. */ public IncludeModifiersNameFilter(@Nullable NameGeneratorFilter next, @Nonnull Collection flags, boolean targetClasses, boolean targetFields, boolean targetMethods) { super(next, false); this.flags = flags.stream().mapToInt(i -> i).toArray(); this.targetClasses = targetClasses; this.targetFields = targetFields; this.targetMethods = targetMethods; } @Override public boolean shouldMapClass(@Nonnull ClassInfo info) { if (targetClasses && info.hasAnyModifiers(flags)) return true; return super.shouldMapClass(info); } @Override public boolean shouldMapField(@Nonnull ClassInfo owner, @Nonnull FieldMember field) { if (targetFields && field.hasAnyModifiers(flags)) return true; return super.shouldMapField(owner, field); } @Override public boolean shouldMapMethod(@Nonnull ClassInfo owner, @Nonnull MethodMember method) { if (targetMethods && method.hasAnyModifiers(flags)) return true; return super.shouldMapMethod(owner, method); } @Override public boolean shouldMapLocalVariable(@Nonnull ClassInfo owner, @Nonnull MethodMember declaringMethod, @Nonnull LocalVariable variable) { // Variables are not targeted, so delegate to next filter return super.shouldMapLocalVariable(owner, declaringMethod, variable); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/filter/IncludeNameFilter.java ================================================ package software.coley.recaf.services.mapping.gen.filter; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.services.search.match.StringPredicate; /** * Filter that includes classes, fields, and methods by their names. * * @author Matt Coley * @see ExcludeNameFilter */ public class IncludeNameFilter extends NameGeneratorFilter { private final StringPredicate classPredicate; private final StringPredicate fieldPredicate; private final StringPredicate methodPredicate; private final StringPredicate variablePredicate; /** * @param next * Next filter to link. Chaining filters allows for {@code thisFilter && nextFilter}. * @param classPredicate * Class name predicate for excluded names. * {@code null} to skip filtering for class names. * @param fieldPredicate * Field name predicate for excluded names. * {@code null} to skip filtering for field names. * @param methodPredicate * Method name predicate for excluded names. * {@code null} to skip filtering for method names. * @param variablePredicate * Variable name predicate for excluded names. * {@code null} to skip filtering for variable names. */ public IncludeNameFilter(@Nullable NameGeneratorFilter next, @Nullable StringPredicate classPredicate, @Nullable StringPredicate fieldPredicate, @Nullable StringPredicate methodPredicate, @Nullable StringPredicate variablePredicate) { super(next, false); this.classPredicate = classPredicate; this.fieldPredicate = fieldPredicate; this.methodPredicate = methodPredicate; this.variablePredicate = variablePredicate; } @Override public boolean shouldMapClass(@Nonnull ClassInfo info) { if (classPredicate != null && classPredicate.match(info.getName())) return true; return super.shouldMapClass(info); } @Override public boolean shouldMapField(@Nonnull ClassInfo owner, @Nonnull FieldMember field) { if (fieldPredicate != null && fieldPredicate.match(field.getName())) return true; return super.shouldMapField(owner, field); } @Override public boolean shouldMapMethod(@Nonnull ClassInfo owner, @Nonnull MethodMember method) { if (methodPredicate != null && methodPredicate.match(method.getName())) return true; return super.shouldMapMethod(owner, method); } @Override public boolean shouldMapLocalVariable(@Nonnull ClassInfo owner, @Nonnull MethodMember declaringMethod, @Nonnull LocalVariable variable) { if (variablePredicate != null && variablePredicate.match(variable.getName())) return true; return super.shouldMapLocalVariable(owner, declaringMethod, variable); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/filter/IncludeNonAsciiNameFilter.java ================================================ package software.coley.recaf.services.mapping.gen.filter; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.ClassMember; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; /** * Filter that includes names that are outside the standard ASCII range used for normal class/member names. * * @author Matt Coley */ public class IncludeNonAsciiNameFilter extends NameGeneratorFilter { /** * @param next * Next filter to link. Chaining filters allows for {@code thisFilter && nextFilter}. */ public IncludeNonAsciiNameFilter(@Nullable NameGeneratorFilter next) { super(next, false); } @Override public boolean shouldMapClass(@Nonnull ClassInfo info) { if (shouldMap(info)) return true; return super.shouldMapClass(info); } @Override public boolean shouldMapField(@Nonnull ClassInfo owner, @Nonnull FieldMember field) { if (shouldMap(field)) return true; return super.shouldMapField(owner, field); } @Override public boolean shouldMapMethod(@Nonnull ClassInfo owner, @Nonnull MethodMember method) { if (shouldMap(method)) return true; return super.shouldMapMethod(owner, method); } @Override public boolean shouldMapLocalVariable(@Nonnull ClassInfo owner, @Nonnull MethodMember declaringMethod, @Nonnull LocalVariable variable) { if (shouldMap(variable)) return true; return super.shouldMapLocalVariable(owner, declaringMethod, variable); } private static boolean shouldMap(ClassInfo info) { return shouldMap(info.getName()); } private static boolean shouldMap(ClassMember info) { return shouldMap(info.getName()); } private static boolean shouldMap(LocalVariable info) { return shouldMap(info.getName()); } private static boolean shouldMap(String name) { return name.codePoints() .anyMatch(code -> (code < 0x21 || code > 0x7A)); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/filter/IncludeNonJavaIdentifierNameFilter.java ================================================ package software.coley.recaf.services.mapping.gen.filter; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.util.StringUtil; import java.util.List; import java.util.Set; /** * Filter that includes names that do not comply with {@link Character#isJavaIdentifierStart(char)} and {@link Character#isJavaIdentifierPart(char)}. * * @author Matt Coley */ public class IncludeNonJavaIdentifierNameFilter extends NameGeneratorFilter { private static final Set classExemptions = Set.of("package-info", "module-info"); /** * @param next * Next filter to link. Chaining filters allows for {@code thisFilter && nextFilter}. */ public IncludeNonJavaIdentifierNameFilter(@Nullable NameGeneratorFilter next) { super(next, false); } @Override public boolean shouldMapClass(@Nonnull ClassInfo info) { String name = info.getName(); // Filter out package/module-info classes if (name.endsWith("package-info")) name = name.substring(0, name.length() - "package-info".length()); else if (name.endsWith("module-info")) name = name.substring(0, name.length() - "module-info".length()); if (isInvalidName(name)) return true; return super.shouldMapClass(info); } @Override public boolean shouldMapField(@Nonnull ClassInfo owner, @Nonnull FieldMember info) { if (isInvalidName(info.getName())) return true; return super.shouldMapField(owner, info); } @Override public boolean shouldMapMethod(@Nonnull ClassInfo owner, @Nonnull MethodMember info) { if (isInvalidName(info.getName())) return true; return super.shouldMapMethod(owner, info); } @Override public boolean shouldMapLocalVariable(@Nonnull ClassInfo owner, @Nonnull MethodMember declaringMethod, @Nonnull LocalVariable variable) { if (isInvalidName(variable.getName())) return true; return super.shouldMapLocalVariable(owner, declaringMethod, variable); } private static boolean isInvalidName(@Nonnull String name) { List parts = StringUtil.fastSplitNonIdentifier(name); for (String part : parts) { int length = part.length(); if (length == 0) return true; else if (length == 1) return !Character.isJavaIdentifierStart(part.charAt(0)); else { char[] chars = part.toCharArray(); if (!Character.isJavaIdentifierStart(chars[0])) return true; for (int i = 1; i < chars.length; i++) { if (!Character.isJavaIdentifierPart(chars[i])) return true; } } } return false; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/filter/IncludeWhitespaceNameFilter.java ================================================ package software.coley.recaf.services.mapping.gen.filter; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.ClassMember; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.util.EscapeUtil; /** * Filter that includes names that contain whitespaces, which are illegal in standard Java source. * * @author Matt Coley */ public class IncludeWhitespaceNameFilter extends NameGeneratorFilter { /** * @param next * Next filter to link. Chaining filters allows for {@code thisFilter && nextFilter}. */ public IncludeWhitespaceNameFilter(@Nullable NameGeneratorFilter next) { super(next, false); } @Override public boolean shouldMapClass(@Nonnull ClassInfo info) { if (shouldMap(info)) return true; return super.shouldMapClass(info); } @Override public boolean shouldMapField(@Nonnull ClassInfo owner, @Nonnull FieldMember field) { if (shouldMap(field)) return true; return super.shouldMapField(owner, field); } @Override public boolean shouldMapMethod(@Nonnull ClassInfo owner, @Nonnull MethodMember method) { if (shouldMap(method)) return true; return super.shouldMapMethod(owner, method); } @Override public boolean shouldMapLocalVariable(@Nonnull ClassInfo owner, @Nonnull MethodMember declaringMethod, @Nonnull LocalVariable variable) { if (shouldMap(variable)) return true; return super.shouldMapLocalVariable(owner, declaringMethod, variable); } private static boolean shouldMap(ClassInfo info) { return shouldMap(info.getName()); } private static boolean shouldMap(ClassMember member) { return shouldMap(member.getName()); } private static boolean shouldMap(LocalVariable variable) { return shouldMap(variable.getName()); } private static boolean shouldMap(String name) { return EscapeUtil.containsWhitespace(name); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/filter/NameGeneratorFilter.java ================================================ package software.coley.recaf.services.mapping.gen.filter; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; /** * Base filter outline. An implementation of a filter would expand or limit the scope of the generated mappings. * * @author Matt Coley */ public abstract class NameGeneratorFilter { private final NameGeneratorFilter next; private final boolean defaultMap; /** * @param next * Next filter to link. Chaining filters allows for {@code thisFilter && nextFilter}. * @param defaultMap * {@code true} to make renaming things the default, treating chains as limitations on the baseline. * {@code false} to make keeping names the default, treating chains as expansions on the baseline. */ protected NameGeneratorFilter(@Nullable NameGeneratorFilter next, boolean defaultMap) { this.next = next; this.defaultMap = defaultMap; } /** * @return {@code true} to make renaming things the default, treating chains as limitations on the baseline. * {@code false} to make keeping names the default, treating chains as expansions on the baseline. */ public boolean isDefaultMap() { return defaultMap; } /** * @param info * Class to check. * * @return {@code true} if the generator should create a new name for the class. */ public boolean shouldMapClass(@Nonnull ClassInfo info) { if (defaultMap) return next == null || next.shouldMapClass(info); else return next != null && next.shouldMapClass(info); } /** * @param owner * Class the field is defined in. * @param field * Field to check. * * @return {@code true} if the generator should create a new name for the field. */ public boolean shouldMapField(@Nonnull ClassInfo owner, @Nonnull FieldMember field) { if (defaultMap) return next == null || next.shouldMapField(owner, field); else return next != null && next.shouldMapField(owner, field); } /** * @param owner * Class the method is defined in. * @param method * Method to check. * * @return {@code true} if the generator should create a new name for the method. */ public boolean shouldMapMethod(@Nonnull ClassInfo owner, @Nonnull MethodMember method) { if (defaultMap) return next == null || next.shouldMapMethod(owner, method); else return next != null && next.shouldMapMethod(owner, method); } /** * @param owner * Class the method is defined in. * @param declaringMethod * Method the variable is defined in. * @param variable Variable to check. * * @return {@code true} if the generator should create a new name for the method. */ public boolean shouldMapLocalVariable(@Nonnull ClassInfo owner, @Nonnull MethodMember declaringMethod, @Nonnull LocalVariable variable) { if (defaultMap) return next == null || next.shouldMapLocalVariable(owner, declaringMethod, variable); else return next != null && next.shouldMapLocalVariable(owner, declaringMethod, variable); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/naming/AbstractNameGeneratorProvider.java ================================================ package software.coley.recaf.services.mapping.gen.naming; import jakarta.annotation.Nonnull; import software.coley.recaf.config.BasicConfigContainer; /** * Abstract base provider for a {@link NameGenerator} implementation. * * @param * Name generator implementation type. * * @author Matt Coley */ public abstract class AbstractNameGeneratorProvider extends BasicConfigContainer implements NameGeneratorProvider { /** * @param id * Name generator ID. */ public AbstractNameGeneratorProvider(@Nonnull String id) { super(GROUP_ID, id); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/naming/AlphabetNameGenerator.java ================================================ package software.coley.recaf.services.mapping.gen.naming; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.util.StringUtil; import software.coley.recaf.workspace.model.Workspace; /** * Basic name generator using a given alphabet of characters to generate pseudo-random names with. * Names will always yield the same value for the same input. * * @author Matt Coley */ public class AlphabetNameGenerator implements DeconflictingNameGenerator { private Workspace workspace; private final String alphabet; private final int length; /** * @param alphabet * Alphabet to use. * @param length * Length of output names. */ public AlphabetNameGenerator(@Nonnull String alphabet, int length) { this.alphabet = alphabet; this.length = length; } @Nonnull private String name(@Nullable String original) { int seed = original == null ? alphabet.hashCode() : original.hashCode(); String name = StringUtil.generateName(alphabet, length, seed); if (workspace != null) { while (workspace.findClass(name) != null) name = StringUtil.generateName(alphabet, length, seed++); } return name; } @Override public void setWorkspace(@Nullable Workspace workspace) { this.workspace = workspace; } @Nonnull @Override public String mapClass(@Nonnull ClassInfo info) { if (info.isInDefaultPackage()) return name(info.getName()); // Ensure classes in the same package are kept together return name(info.getPackageName()) + "/" + name(info.getName()); } @Nonnull @Override public String mapField(@Nonnull ClassInfo owner, @Nonnull FieldMember field) { return name(owner.getName() + "#" + field.getName()); } @Nonnull @Override public String mapMethod(@Nonnull ClassInfo owner, @Nonnull MethodMember method) { return name(owner.getName() + "#" + method.getName()); } @Nonnull @Override public String mapVariable(@Nonnull ClassInfo owner, @Nonnull MethodMember declaringMethod, @Nonnull LocalVariable variable) { return name(owner.getName() + "#" + declaringMethod.getName() + "#" + variable.getIndex() + "#" + variable.getName() + "#" + variable.getDescriptor()); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/naming/AlphabetNameGeneratorProvider.java ================================================ package software.coley.recaf.services.mapping.gen.naming; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.observables.ObservableInteger; import software.coley.observables.ObservableString; import software.coley.recaf.config.BasicConfigValue; /** * Name generator provider for {@link AlphabetNameGenerator}. * * @author Matt Coley */ @ApplicationScoped public class AlphabetNameGeneratorProvider extends AbstractNameGeneratorProvider { public static final String ID = "alphabet"; private final ObservableString alphabet = new ObservableString("abcdefghijklmnopqrstuvwxyz"); private final ObservableInteger length = new ObservableInteger(3); @Inject public AlphabetNameGeneratorProvider() { super(ID); addValue(new BasicConfigValue<>("alphabet", String.class, alphabet)); addValue(new BasicConfigValue<>("length", int.class, length)); } @Nonnull @Override public AlphabetNameGenerator createGenerator() { return new AlphabetNameGenerator(alphabet.getValue(), length.getValue()); } /** * @return Alphabet of characters to use when creating names. */ @Nonnull public ObservableString getAlphabet() { return alphabet; } /** * @return Length of output names. */ @Nonnull public ObservableInteger getLength() { return length; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/naming/DeconflictingNameGenerator.java ================================================ package software.coley.recaf.services.mapping.gen.naming; import jakarta.annotation.Nullable; import software.coley.recaf.workspace.model.Workspace; /** * Name generation outline that supports deconflicting cases where two items may create the same name. * * @author Matt Coley */ public interface DeconflictingNameGenerator extends NameGenerator { /** * Enables name deconfliction. * * @param workspace * Workspace to assign, to deconflict names. */ void setWorkspace(@Nullable Workspace workspace); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/naming/IncrementingNameGenerator.java ================================================ package software.coley.recaf.services.mapping.gen.naming; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.workspace.model.Workspace; /** * Basic name generator using a given alphabet of characters to generate pseudo-random names with. * Names will always yield the same value for the same input. * * @author Matt Coley */ public class IncrementingNameGenerator implements DeconflictingNameGenerator { private Workspace workspace; private long classIndex = 1; private long fieldIndex = 1; private long methodIndex = 1; private long varIndex = 1; @Nonnull private String nextClassName() { return "mapped/Class" + classIndex++; } @Nonnull private String nextFieldName() { return "field" + fieldIndex++; } @Nonnull private String nextMethodName() { return "method" + methodIndex++; } @Nonnull private String nextVarName() { return "var" + varIndex++; } @Override public void setWorkspace(@Nullable Workspace workspace) { this.workspace = workspace; } @Nonnull @Override public String mapClass(@Nonnull ClassInfo info) { String name = nextClassName(); if (workspace != null) { while (workspace.findClass(name) != null) name = nextClassName(); } return name; } @Nonnull @Override public String mapField(@Nonnull ClassInfo owner, @Nonnull FieldMember field) { String name = nextFieldName(); String descriptor = field.getDescriptor(); while (owner.getDeclaredField(name, descriptor) != null) name = nextFieldName(); return name; } @Nonnull @Override public String mapMethod(@Nonnull ClassInfo owner, @Nonnull MethodMember method) { String name = nextMethodName(); String descriptor = method.getDescriptor(); while (owner.getDeclaredMethod(name, descriptor) != null) name = nextMethodName(); return name; } @Nonnull @Override public String mapVariable(@Nonnull ClassInfo owner, @Nonnull MethodMember declaringMethod, @Nonnull LocalVariable variable) { return nextVarName(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/naming/IncrementingNameGeneratorProvider.java ================================================ package software.coley.recaf.services.mapping.gen.naming; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; /** * Name generator provider for {@link IncrementingNameGenerator}. * * @author Matt Coley */ @ApplicationScoped public class IncrementingNameGeneratorProvider extends AbstractNameGeneratorProvider { public static final String ID = "incrementing"; @Inject public IncrementingNameGeneratorProvider() { super(ID); } @Nonnull @Override public IncrementingNameGenerator createGenerator() { return new IncrementingNameGenerator(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/naming/NameGenerator.java ================================================ package software.coley.recaf.services.mapping.gen.naming; import jakarta.annotation.Nonnull; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; /** * Base name generation outline. * * @author Matt Coley */ public interface NameGenerator { /** * @param info * Class to rename. * * @return New class name. */ @Nonnull String mapClass(@Nonnull ClassInfo info); /** * @param owner * Class the field is defined in. * @param field * Field to rename. * * @return New field name. */ @Nonnull String mapField(@Nonnull ClassInfo owner, @Nonnull FieldMember field); /** * @param owner * Class the method is defined in. * @param method * Method to rename. * * @return New method name. */ @Nonnull String mapMethod(@Nonnull ClassInfo owner, @Nonnull MethodMember method); /** * @param owner * Class the method is defined in. * @param declaringMethod * Method the variable is defined in. * @param variable * Variable to rename. * * @return New variable name. */ @Nonnull String mapVariable(@Nonnull ClassInfo owner, @Nonnull MethodMember declaringMethod, @Nonnull LocalVariable variable); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/naming/NameGeneratorProvider.java ================================================ package software.coley.recaf.services.mapping.gen.naming; import jakarta.annotation.Nonnull; import software.coley.recaf.config.ConfigContainer; import software.coley.recaf.config.ConfigGroups; /** * Provider for a {@link NameGenerator} implementation. * Each provider should be configured as a {@link ConfigContainer}. * * @param * Name generator implementation type. * * @author Matt Coley * @see AbstractNameGeneratorProvider Base abstract implementation. */ public interface NameGeneratorProvider extends ConfigContainer { /** * Group ID for {@link ConfigContainer#getGroup()}. */ String GROUP_ID = ConfigGroups.SERVICE_MAPPING + ConfigGroups.PACKAGE_SPLIT + "name-gen-provider"; /** * @return New instance of {@link NameGenerator} implementation. */ @Nonnull T createGenerator(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/naming/NameGeneratorProviders.java ================================================ package software.coley.recaf.services.mapping.gen.naming; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; import software.coley.recaf.services.Service; import java.util.Collections; import java.util.HashMap; import java.util.Map; /** * Service managing available {@link NameGeneratorProvider} types. * * @author Matt Coley */ @ApplicationScoped public class NameGeneratorProviders implements Service { public static final String SERVICE_ID = "name-gen-providers"; private final NameGeneratorProvidersConfig config; private final Map> providerMap = new HashMap<>(); @Inject public NameGeneratorProviders(@Nonnull NameGeneratorProvidersConfig config, @Nonnull Instance> providers) { this.config = config; for (NameGeneratorProvider provider : providers) providerMap.put(provider.getId(), provider); } /** * @param provider * New provider to add. * * @throws IllegalStateException * When a provider with the given ID already is registered.z */ public void registerProvider(@Nonnull NameGeneratorProvider provider) { String id = provider.getId(); if (providerMap.get(id) != null) throw new IllegalStateException("A provider with the given id '" + id + "' already exists!"); providerMap.put(id, provider); } /** * @return Map of available providers. */ @Nonnull public Map> getProviders() { return Collections.unmodifiableMap(providerMap); } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public NameGeneratorProvidersConfig getServiceConfig() { return config; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/mapping/gen/naming/NameGeneratorProvidersConfig.java ================================================ package software.coley.recaf.services.mapping.gen.naming; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link NameGeneratorProviders}. * * @author Matt Coley */ @ApplicationScoped public class NameGeneratorProvidersConfig extends BasicConfigContainer implements ServiceConfig { @Inject public NameGeneratorProvidersConfig() { super(ConfigGroups.SERVICE_MAPPING, NameGeneratorProviders.SERVICE_ID + CONFIG_SUFFIX); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/phantom/GeneratedPhantomWorkspaceResource.java ================================================ package software.coley.recaf.services.phantom; import jakarta.annotation.Nonnull; import software.coley.recaf.workspace.model.bundle.AndroidClassBundle; import software.coley.recaf.workspace.model.bundle.FileBundle; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.bundle.VersionedJvmClassBundle; import software.coley.recaf.workspace.model.resource.BasicWorkspaceResource; import software.coley.recaf.workspace.model.resource.WorkspaceFileResource; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import software.coley.recaf.workspace.model.resource.WorkspaceResourceBuilder; import java.util.Map; import java.util.NavigableMap; /** * A special sub-type of a workspace resource indicating it originates from {@link PhantomGenerator}. * * @author Matt Coley */ public class GeneratedPhantomWorkspaceResource extends BasicWorkspaceResource { /** * @param builder * Builder to pull information from. */ public GeneratedPhantomWorkspaceResource(@Nonnull WorkspaceResourceBuilder builder) { super(builder); } /** * @param jvmClassBundle * Immediate classes. * @param fileBundle * Immediate files. * @param versionedJvmClassBundles * Version specific classes. * @param androidClassBundles * Android bundles. * @param embeddedResources * Embedded resources (like JAR in JAR) * @param containingResource * Parent resource (If we are the JAR within a JAR). */ public GeneratedPhantomWorkspaceResource(JvmClassBundle jvmClassBundle, FileBundle fileBundle, NavigableMap versionedJvmClassBundles, Map androidClassBundles, Map embeddedResources, WorkspaceResource containingResource) { super(jvmClassBundle, fileBundle, versionedJvmClassBundles, androidClassBundles, embeddedResources, containingResource); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/phantom/JPhantomGenerator.java ================================================ package software.coley.recaf.services.phantom; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.clyze.jphantom.ClassMembers; import org.clyze.jphantom.JPhantom; import org.clyze.jphantom.Options; import org.clyze.jphantom.Phantoms; import org.clyze.jphantom.access.ClassAccessStateMachine; import org.clyze.jphantom.access.FieldAccessStateMachine; import org.clyze.jphantom.access.MethodAccessStateMachine; import org.clyze.jphantom.adapters.ClassPhantomExtractor; import org.clyze.jphantom.hier.ClassHierarchy; import org.clyze.jphantom.hier.IncrementalClassHierarchy; import org.objectweb.asm.*; import org.objectweb.asm.tree.ClassNode; import org.slf4j.Logger; import software.coley.recaf.RecafConstants; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.cdi.EagerInitialization; import software.coley.recaf.info.Info; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.builder.JvmClassInfoBuilder; import software.coley.recaf.services.workspace.WorkspaceManager; import software.coley.recaf.util.ReflectUtil; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.BasicJvmClassBundle; import software.coley.recaf.workspace.model.bundle.Bundle; import software.coley.recaf.workspace.model.resource.WorkspaceResourceBuilder; import java.io.IOException; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.function.Function; import java.util.stream.Collectors; /** * An implementation of {@link PhantomGenerator} using {@link JPhantom}. * * @author Matt Coley */ @ApplicationScoped @EagerInitialization public class JPhantomGenerator implements PhantomGenerator { public static final String SERVICE_ID = "jphantom-generator"; private static final Logger logger = Logging.get(JPhantomGenerator.class); private final JPhantomGeneratorConfig config; @Inject public JPhantomGenerator(@Nonnull JPhantomGeneratorConfig config, @Nonnull WorkspaceManager workspaceManager) { this.config = config; // When new workspaces are opened, generate & append the generated phantoms if the config is enabled. workspaceManager.addWorkspaceOpenListener(workspace -> { if (!config.getGenerateWorkspacePhantoms().getValue()) return; CompletableFuture.supplyAsync(() -> { try { return createPhantomsForWorkspace(workspace); } catch (Throwable t) { // Workspace-level phantoms are useful for some graphing operations and can be used to enhance compile tasks. // Though, if workspace-level phantoms are not made the compiler will create class-level phantoms anyways, // so this failing is not a big deal. Happens fairly regularly in obfuscated inputs. logger.warn("Failed to generate phantoms for workspace. Some graphing operations may be slightly less effective."); return null; } }).thenAccept(generatedResource -> { if (generatedResource != null) workspace.addSupportingResource(generatedResource); }); }); } @Nonnull @Override public GeneratedPhantomWorkspaceResource createPhantomsForWorkspace(@Nonnull Workspace workspace) throws PhantomGenerationException { // Extract all JVM classes from workspace Map classMap = workspace.getPrimaryResource().jvmAllClassBundleStreamRecursive() .flatMap(Bundle::stream) .collect(Collectors.toMap(Info::getName, Function.identity())); // Generate phantoms for them and wrap into resource try { Map generated = generate(workspace, classMap); return wrap(generated); } catch (IOException ex) { throw new PhantomGenerationException(ex, "JPhantom encountered a problem"); } } @Nonnull @Override public GeneratedPhantomWorkspaceResource createPhantomsForClasses(@Nonnull Workspace workspace, @Nonnull Collection classes) throws PhantomGenerationException { // Convert collection to map Map classMap = classes.stream() .collect(Collectors.toMap(Info::getName, Function.identity(), (a, b) -> a)); // Generate phantoms for them and wrap into resource try { Map generated = generate(workspace, classMap); return wrap(generated); } catch (IOException ex) { throw new PhantomGenerationException(ex, "JPhantom encountered a problem"); } } /** * @param generated * Map of generated classes. * * @return Wrapping resource. */ @Nonnull public static GeneratedPhantomWorkspaceResource wrap(@Nonnull Map generated) { // Wrap into resource BasicJvmClassBundle bundle = new BasicJvmClassBundle(); generated.forEach((name, phantom) -> { JvmClassInfo phantomClassInfo = new JvmClassInfoBuilder(phantom).build(); bundle.initialPut(phantomClassInfo); }); bundle.markInitialState(); return new GeneratedPhantomWorkspaceResource(new WorkspaceResourceBuilder() .withJvmClassBundle(bundle)); } /** * @param workspace * Workspace to check for class existence within. * @param inputMap * Input map of classes to create phantoms for. * * @return Map of phantom classes. * * @throws IOException * When {@link JPhantom#run()} fails. */ @Nonnull public static Map generate(@Nonnull Workspace workspace, @Nonnull Map inputMap) throws IOException { Map out = new HashMap<>(); // Write the parameter passed classes to a temp jar Map classMap = new HashMap<>(); Map nodes = new HashMap<>(); inputMap.forEach((name, info) -> { ClassReader cr = info.getClassReader(); ClassNode node = new ClassNode(); cr.accept(node, ClassReader.SKIP_FRAMES); classMap.put(name + ".class", info.getBytecode()); nodes.put(Type.getObjectType(node.name), node); }); // Read into JPhantom Options.V().setSoftFail(true); Options.V().setJavaVersion(8); ClassHierarchy hierarchy = createHierarchy(classMap); ClassMembers members = createMembers(classMap, hierarchy); classMap.forEach((name, raw) -> { if (name.contains("$")) return; try { ClassReader cr = new ClassReader(raw); cr.accept(new ClassPhantomExtractor(hierarchy, members), 0); } catch (Throwable t) { logger.debug("Phantom extraction failed: {}", name, t); } }); // Remove duplicate constraints for faster analysis Set existingConstraints = new HashSet<>(); ClassAccessStateMachine.v().getConstraints().removeIf(c -> !existingConstraints.add(c.toString())); // Execute and populate the current resource with generated classes try { JPhantom phantom = new JPhantom(nodes, hierarchy, members); phantom.run(); phantom.getGenerated().forEach((k, v) -> { // Only put items not found in the workspace. // We may call the generator on a small scope, and thus create phantoms of classes that // exist in the workspace, but were not in the provided scope. String name = k.getInternalName(); if (workspace.findJvmClass(name) == null) out.put(name, decorate(v)); }); logger.debug("Phantom analysis complete, generated {} classes", out.size()); } catch (Throwable t) { logger.error("Phantom analysis encountered an exception.", t); } finally { // Cleanup Phantoms.refresh(); Phantoms.V().getLookupTable().clear(); ClassAccessStateMachine.refresh(); FieldAccessStateMachine.refresh(); MethodAccessStateMachine.refresh(); } return out; } /** * @param classMap * Map to pull classes from. * @param hierarchy * Hierarchy to pass to {@link ClassMembers} constructor. * * @return Members instance. */ @Nonnull public static ClassMembers createMembers(@Nonnull Map classMap, @Nonnull ClassHierarchy hierarchy) { Class[] argTypes = new Class[]{ClassHierarchy.class}; Object[] argVals = new Object[]{hierarchy}; ClassMembers repo = ReflectUtil.quietNew(ClassMembers.class, argTypes, argVals); try { new ClassReader("java/lang/Object").accept(repo.new Feeder(), 0); } catch (IOException ex) { logger.error("Failed to get initial reader ClassMembers, could not lookup 'java/lang/Object'"); throw new IllegalStateException(); } for (Map.Entry e : classMap.entrySet()) { try { new ClassReader(e.getValue()).accept(repo.new Feeder(), 0); } catch (Throwable t) { logger.debug("Could not supply {} to ClassMembers feeder", e.getKey(), t); } } return repo; } /** * @param classMap * Map to pull classes from. * * @return Class hierarchy. */ @Nonnull public static ClassHierarchy createHierarchy(@Nonnull Map classMap) { ClassHierarchy hierarchy = new IncrementalClassHierarchy(); for (Map.Entry e : classMap.entrySet()) { try { ClassReader reader = new ClassReader(e.getValue()); String[] ifaceNames = reader.getInterfaces(); Type clazz = Type.getObjectType(reader.getClassName()); Type superclass = reader.getSuperName() == null ? Type.getObjectType("java/lang/Object") : Type.getObjectType(reader.getSuperName()); Type[] ifaces = new Type[ifaceNames.length]; for (int i = 0; i < ifaces.length; i++) ifaces[i] = Type.getObjectType(ifaceNames[i]); // Add type to hierarchy boolean isInterface = (reader.getAccess() & Opcodes.ACC_INTERFACE) != 0; if (isInterface) { hierarchy.addInterface(clazz, ifaces); } else { hierarchy.addClass(clazz, superclass, ifaces); } } catch (Exception ex) { logger.error("JPhantom: Hierarchy failure for: {}", e.getKey(), ex); } } return hierarchy; } /** * Adds a note to the given class that it has been auto-generated. * * @param generated * Input generated JPhantom class. * * @return Modified class that clearly indicates it is generated. */ @Nonnull private static byte[] decorate(@Nonnull byte[] generated) { ClassReader reader = new ClassReader(generated); ClassWriter writer = new ClassWriter(reader, 0); ClassVisitor annotationInserter = new ClassVisitor(RecafConstants.getAsmVersion(), writer) { @Override public void visitEnd() { visitAnnotation("LAutoGenerated;", true) .visit("msg", "Recaf/JPhantom automatically generated this class"); super.visitEnd(); } }; reader.accept(annotationInserter, ClassReader.SKIP_FRAMES); return writer.toByteArray(); } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public JPhantomGeneratorConfig getServiceConfig() { return config; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/phantom/JPhantomGeneratorConfig.java ================================================ package software.coley.recaf.services.phantom; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.observables.ObservableBoolean; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.BasicConfigValue; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link JPhantomGenerator} * * @author Matt Coley */ @ApplicationScoped public class JPhantomGeneratorConfig extends BasicConfigContainer implements ServiceConfig { private final ObservableBoolean generateWorkspacePhantoms = new ObservableBoolean(false); @Inject public JPhantomGeneratorConfig() { super(ConfigGroups.SERVICE_ANALYSIS, JPhantomGenerator.SERVICE_ID + CONFIG_SUFFIX); addValue(new BasicConfigValue<>("generate-workspace-phantoms", boolean.class, generateWorkspacePhantoms)); } /** * @return {@code true} to create and register {@link GeneratedPhantomWorkspaceResource} to newly opened workspaces. */ @Nullable public ObservableBoolean getGenerateWorkspacePhantoms() { return generateWorkspacePhantoms; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/phantom/PhantomGenerationException.java ================================================ package software.coley.recaf.services.phantom; /** * Exception thrown when {@link PhantomGenerator} operations fail. * * @author Matt Coley */ public class PhantomGenerationException extends Exception { /** * @param cause * Root cause of the failure. * @param message * Additional detail message. */ public PhantomGenerationException(Throwable cause, String message) { super(message, cause); } /** * @param cause * Root cause of the failure. */ public PhantomGenerationException(Throwable cause) { super(cause); } /** * @param message * Additional detail message. */ public PhantomGenerationException(String message) { super(message); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/phantom/PhantomGenerator.java ================================================ package software.coley.recaf.services.phantom; import jakarta.annotation.Nonnull; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.Service; import software.coley.recaf.workspace.model.Workspace; import java.util.Collection; /** * Outline for phantom class generation targeting different levels of scope. * * @author Matt Coley */ public interface PhantomGenerator extends Service { /** * Generates a resource containing the phantom classes necessary to compile the JVM classes in the * primary resource of the given workspace. *

* Do note that this is a largely more computationally complex task than creating phantoms for * {@link #createPhantomsForClasses(Workspace, Collection) one or a few classes at a time}. * * @param workspace * Workspace to scan for classes with missing references. * * @return Resource containing generated phantoms, targeting missing references across all classes. * * @throws PhantomGenerationException * When generating phantoms failed. */ @Nonnull GeneratedPhantomWorkspaceResource createPhantomsForWorkspace(@Nonnull Workspace workspace) throws PhantomGenerationException; /** * Generates a resource containing the phantom classes necessary to compile the given classes. * * @param workspace * Workspace to pull class information from. * If a class that is required for compilation of a given class is in the workspace, then no phantom * for it will be generated in the resulting created resource. * @param classes * Classes to scan for missing references. * * @return Resource containing generated phantoms, targeting only the specified classes. * * @throws PhantomGenerationException * When generating phantoms failed. */ @Nonnull GeneratedPhantomWorkspaceResource createPhantomsForClasses(@Nonnull Workspace workspace, @Nonnull Collection classes) throws PhantomGenerationException; } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/AllocationException.java ================================================ package software.coley.recaf.services.plugin; import jakarta.annotation.Nonnull; /** * Wrapper exception for any potential error thrown by the implementation logic of a {@link ClassAllocator} * * @author Matt Coley */ public class AllocationException extends Exception { private final Class type; /** * @param type * Type that failed to be allocated. * @param cause * Reason for allocation failure. */ public AllocationException(@Nonnull Class type, @Nonnull Throwable cause) { super(cause); this.type = type; } /** * @return Type that failed to be allocated. */ @Nonnull public Class getType() { return type; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/BasicPluginManager.java ================================================ package software.coley.recaf.services.plugin; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.cdi.EagerInitialization; import software.coley.recaf.plugin.*; import software.coley.recaf.services.plugin.discovery.DiscoveredPluginSource; import software.coley.recaf.services.plugin.discovery.PluginDiscoverer; import software.coley.recaf.services.plugin.zip.ZipPluginLoader; import java.util.ArrayList; import java.util.Collection; import java.util.List; /** * Basic implementation of {@link PluginManager}. * * @author xDark */ @ApplicationScoped @EagerInitialization public class BasicPluginManager implements PluginManager { private final List loaders = new ArrayList<>(); private final PluginGraph mainGraph; private final ClassAllocator classAllocator; private final PluginManagerConfig config; @Inject public BasicPluginManager(PluginManagerConfig config, CdiClassAllocator classAllocator) { this.classAllocator = classAllocator; this.config = config; mainGraph = new PluginGraph(classAllocator); // Add ZIP/JAR loading registerLoader(new ZipPluginLoader()); } @Nonnull @Override public ClassAllocator getAllocator() { return classAllocator; } @Override @Nullable @SuppressWarnings("unchecked") public PluginContainer getPlugin(@Nonnull String id) { return (PluginContainer) mainGraph.getContainer(id); } @Nonnull @Override public Collection> getPlugins() { return mainGraph.plugins(); } @Override public void registerLoader(@Nonnull PluginLoader loader) { loaders.add(loader); } @Nonnull @Override public Collection> loadPlugins(@Nonnull PluginDiscoverer discoverer) throws PluginException { List discoveredPlugins = discoverer.findSources(); List loaders = this.loaders; List prepared = new ArrayList<>(discoveredPlugins.size()); for (DiscoveredPluginSource plugin : discoveredPlugins) { for (PluginLoader loader : loaders) { PreparedPlugin preparedPlugin = loader.prepare(plugin.source()); if (preparedPlugin == null) continue; prepared.add(preparedPlugin); } } return mainGraph.apply(prepared); } @Nonnull @Override public PluginUnloader unloaderFor(@Nonnull String id) { return mainGraph.unloaderFor(id); } @Override public boolean isPluginLoaded(@Nonnull String id) { return mainGraph.getContainer(id) != null; } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public PluginManagerConfig getServiceConfig() { return config; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/CdiClassAllocator.java ================================================ package software.coley.recaf.services.plugin; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.spi.CreationalContext; import jakarta.enterprise.inject.spi.*; import jakarta.inject.Inject; import java.util.IdentityHashMap; import java.util.Map; /** * Allocator instance that ties into the CDI container. * * @author Matt Coley */ @ApplicationScoped public class CdiClassAllocator implements ClassAllocator { private final Map, Bean> classBeanMap = new IdentityHashMap<>(); private final BeanManager beanManager; @Inject public CdiClassAllocator(@Nonnull BeanManager beanManager) { this.beanManager = beanManager; } @Nonnull @Override @SuppressWarnings("unchecked") public T instance(@Nonnull Class cls) throws AllocationException { try { // Create bean Bean bean = (Bean) classBeanMap.computeIfAbsent(cls, c -> { AnnotatedType annotatedClass = beanManager.createAnnotatedType(cls); BeanAttributes attributes = beanManager.createBeanAttributes(annotatedClass); InjectionTargetFactory factory = beanManager.getInjectionTargetFactory(annotatedClass); return beanManager.createBean(attributes, cls, factory); }); CreationalContext creationalContext = beanManager.createCreationalContext(bean); // Allocate instance of bean return bean.create(creationalContext); } catch (Throwable t) { throw new AllocationException(cls, t); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/ClassAllocator.java ================================================ package software.coley.recaf.services.plugin; import jakarta.annotation.Nonnull; /** * Responsible for creating new class instances from a {@link Class} reference. * * @author Matt Coley */ public interface ClassAllocator { /** * @param cls * Class to allocate an instance of. * @param * Type of class. * * @return Instance of T type. * * @throws AllocationException * When any error prevents a new instance from being provided. */ @Nonnull T instance(@Nonnull Class cls) throws AllocationException; } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/LoadedPlugin.java ================================================ package software.coley.recaf.services.plugin; import jakarta.annotation.Nonnull; import java.util.HashSet; import java.util.Set; /** * Model of a loaded plugin and its dependence for {@link PluginGraph}. * * @author xDark */ final class LoadedPlugin { private final Set dependencies = HashSet.newHashSet(4); private final PluginContainerImpl container; LoadedPlugin(@Nonnull PluginContainerImpl container) { this.container = container; } /** * @return Mutable set of dependencies this plugin relies on. */ @Nonnull public Set getDependencies() { return dependencies; } /** * @return Container the loaded plugin belongs to. */ @Nonnull public PluginContainerImpl getContainer() { return container; } @Override public String toString() { return "LoadedPlugin{" + container.info().id() + '}'; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/PluginClassLoader.java ================================================ package software.coley.recaf.services.plugin; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.util.io.ByteSource; /** * Plugin class loader interface. */ public interface PluginClassLoader { /** * @param name * Resource path. * * @return Resource source or {@code null} if not found. */ @Nullable ByteSource lookupResource(@Nonnull String name); /** * @param name * Class name. * * @return Class. * * @throws ClassNotFoundException * If class was not found. */ @Nonnull Class lookupClass(@Nonnull String name) throws ClassNotFoundException; } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/PluginClassLoaderImpl.java ================================================ package software.coley.recaf.services.plugin; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.util.io.ByteSource; import java.io.IOException; import java.io.InputStream; import java.net.*; /** * Classloader for plugin content. * * @author xDark */ final class PluginClassLoaderImpl extends ClassLoader implements PluginClassLoader { private final PluginGraph graph; private final PluginSource source; private final String id; PluginClassLoaderImpl(@Nonnull ClassLoader classLoader, @Nonnull PluginGraph graph, @Nonnull PluginSource source, @Nonnull String id) { super(classLoader); this.graph = graph; this.source = source; this.id = id; } @Override protected URL findResource(String name) { ByteSource source = this.source.findResource(name); if (source == null) { return null; } try { URI uri = new URI("recaf", "/", name); return URL.of(uri, new URLStreamHandler() { @Override protected URLConnection openConnection(URL u) { return new URLConnection(u) { InputStream in; @Override public void connect() { // no-op } @Override public InputStream getInputStream() throws IOException { InputStream in = this.in; if (in == null) { in = source.openStream(); this.in = in; } return in; } }; } }); } catch (MalformedURLException | URISyntaxException ex) { throw new IllegalStateException(ex); } } @Nullable @Override public ByteSource lookupResource(@Nonnull String name) { return source.findResource(name); } @Nonnull @Override public Class lookupClass(@Nonnull String name) throws ClassNotFoundException { Class cls = lookupClassImpl(name); if (cls == null) { throw new ClassNotFoundException(name); } return cls; } @Override protected Class findClass(String name) throws ClassNotFoundException { Class cls = lookupClassImpl(name); if (cls != null) return cls; var dependencyLoaders = graph.getDependencyClassloaders(id); while (dependencyLoaders.hasNext()) { if ((cls = dependencyLoaders.next().findClass(name)) != null) return cls; } throw new ClassNotFoundException(name); } @Nullable Class lookupClassImpl(@Nonnull String name) throws ClassNotFoundException { Class cls = findLoadedClass(name); if (cls != null) return cls; ByteSource classBytes = source.findResource(name.replace('.', '/') + ".class"); if (classBytes != null) { byte[] bytes; try { bytes = classBytes.readAll(); } catch (IOException ex) { throw new ClassNotFoundException(name, ex); } return defineClass(name, bytes, 0, bytes.length); } return null; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/PluginContainer.java ================================================ package software.coley.recaf.services.plugin; import jakarta.annotation.Nonnull; import software.coley.recaf.plugin.Plugin; /** * Object that holds reference to a plugin and it's information. * * @param

* Plugin instance type. * * @author xDark * @see PluginInfo * @see PluginLoader */ public interface PluginContainer

{ /** * @return Plugin information. */ @Nonnull PluginInfo info(); /** * @return Plugin instance. */ @Nonnull P plugin(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/PluginContainerImpl.java ================================================ package software.coley.recaf.services.plugin; import jakarta.annotation.Nonnull; import software.coley.recaf.plugin.Plugin; /** * Plugin container implementation. * * @param

* Plugin instance type. * * @author xDark */ final class PluginContainerImpl

implements PluginContainer

{ final PreparedPlugin preparedPlugin; final PluginClassLoader classLoader; P plugin; PluginContainerImpl(@Nonnull PreparedPlugin preparedPlugin, @Nonnull PluginClassLoader classLoader) { this.preparedPlugin = preparedPlugin; this.classLoader = classLoader; } @Nonnull @Override public PluginInfo info() { return preparedPlugin.info(); } @Nonnull @Override public P plugin() { P plugin = this.plugin; if (plugin == null) { throw new IllegalStateException("Uninitialized plugin"); } return plugin; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/PluginException.java ================================================ package software.coley.recaf.services.plugin; /** * Exception thrown when action involving plugins fail. * * @author xDark */ public final class PluginException extends Exception { /** * Constructs a new exception. */ public PluginException() { } /** * Constructs a new exception. * * @param message * The detail message. */ public PluginException(String message) { super(message); } /** * Constructs a new exception. * * @param message * The detail message. * @param cause * The cause of the exception. */ public PluginException(String message, Throwable cause) { super(message, cause); } /** * Constructs a new exception. * * @param cause * The cause of the exception. */ public PluginException(Throwable cause) { super(cause); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/PluginGraph.java ================================================ package software.coley.recaf.services.plugin; import com.google.common.collect.Collections2; import com.google.common.collect.Iterators; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.plugin.*; import java.util.*; import java.util.stream.Stream; /** * Plugin dependency graph. * * @author xDark */ final class PluginGraph { final Map plugins = HashMap.newHashMap(16); private final ClassAllocator classAllocator; /** * @param classAllocator * Allocator to construct plugin instances with. */ PluginGraph(@Nonnull ClassAllocator classAllocator) { this.classAllocator = classAllocator; } /** * Primary plugin load action. * * @param preparedPlugins * Intermediate plugin data to load. * * @return Collection of loaded plugin containers. * * @throws PluginException * If the plugins could not be loaded for any reason. */ @Nonnull Collection> apply(@Nonnull List preparedPlugins) throws PluginException { Map temp = LinkedHashMap.newLinkedHashMap(preparedPlugins.size()); var plugins = this.plugins; for (var preparedPlugin : preparedPlugins) { String id = preparedPlugin.info().id(); if (plugins.containsKey(id)) { throw new PluginException("Plugin %s is already loaded".formatted(id)); } var threadContextClassLoader = Thread.currentThread().getContextClassLoader(); var parentLoader = threadContextClassLoader != null ? threadContextClassLoader : ClassLoader.getSystemClassLoader(); var classLoader = new PluginClassLoaderImpl(parentLoader, this, preparedPlugin.pluginSource(), id); LoadedPlugin loadedPlugin = new LoadedPlugin(new PluginContainerImpl<>(preparedPlugin, classLoader)); if (temp.putIfAbsent(id, loadedPlugin) != null) { throw new PluginException("Duplicate plugin %s".formatted(id)); } } for (LoadedPlugin plugin : temp.values()) { PluginInfo info = plugin.getContainer().info(); for (String dependencyId : info.dependencies()) { LoadedPlugin dep = temp.get(dependencyId); if (dep == null) { dep = plugins.get(dependencyId); } if (dep == null) { throw new PluginException("Plugin %s is missing dependency %s".formatted(info.id(), dependencyId)); } plugin.getDependencies().add(dep); } for (String dependencyId : info.softDependencies()) { LoadedPlugin dep = temp.get(dependencyId); if (dep == null && (dep = plugins.get(dependencyId)) == null) { continue; } plugin.getDependencies().add(dep); } } for (LoadedPlugin loadedPlugin : temp.values()) { try { enable(loadedPlugin); } catch (PluginException ex) { for (LoadedPlugin pl : temp.values()) { PluginContainerImpl container = pl.getContainer(); try { try { Plugin maybeEnabled = container.plugin; if (maybeEnabled != null) { maybeEnabled.onDisable(); } } finally { container.preparedPlugin.reject(); } } catch (Exception ex1) { ex.addSuppressed(ex1); } } throw ex; } } plugins.putAll(temp); return Collections2.transform(temp.values(), input -> input.getContainer()); } /** * @param id * Plugin identifier. * * @return Plugin unload action. */ @Nonnull PluginUnloader unloaderFor(@Nonnull String id) { LoadedPlugin plugin = plugins.get(id); if (plugin == null) { throw new IllegalStateException("Plugin %s is not loaded".formatted(id)); } Map> dependants = HashMap.newHashMap(8); collectDependants(plugin, dependants); return new PluginUnloader() { @Override public void commit() throws PluginException { PluginException ex = unload(plugin, dependants); if (ex != null) throw ex; } @Nonnull @Override public PluginInfo unloadingPlugin() { return plugin.getContainer().info(); } @Nonnull @Override public Stream dependants() { return dependants.values() .stream() .flatMap(Collection::stream) .map(plugin -> plugin.getContainer().info()); } }; } /** * Attempts to unload the given plugin, along with its dependants. * * @param plugin * Plugin to unload. * @param dependants * Map of plugin dependents. * * @return Exception to be thrown if the plugin could not be unloaded. */ @Nullable private PluginException unload(@Nonnull LoadedPlugin plugin, @Nonnull Map> dependants) { String id = plugin.getContainer().info().id(); if (!plugins.remove(id, plugin)) { throw new IllegalStateException("Plugin %s was already removed, recursion?".formatted(id)); } PluginException exception = null; for (LoadedPlugin dependant : dependants.get(plugin)) { PluginException inner = unload(dependant, dependants); if (inner != null) { if (exception == null) { exception = inner; } else { exception.addSuppressed(inner); } } } try { PluginContainerImpl container = plugin.getContainer(); try { container.plugin().onDisable(); } finally { if (container.classLoader instanceof AutoCloseable ac) { ac.close(); } } } catch (Exception ex) { PluginException pex = new PluginException(ex); if (exception == null) { exception = pex; } else { exception.addSuppressed(pex); } } return exception; } /** * Iterates over which plugins depend on the given plugin, and stores them in the provided map. * * @param plugin * Plugin to collect dependants of. * @param dependants * Map to store results in. */ private void collectDependants(@Nonnull LoadedPlugin plugin, @Nonnull Map> dependants) { Set dependantsSet = dependants.computeIfAbsent(plugin, __ -> HashSet.newHashSet(4)); for (LoadedPlugin pl : plugins.values()) { if (plugin == pl) continue; if (pl.getDependencies().contains(plugin)) { dependantsSet.add(pl); collectDependants(pl, dependants); } } } /** * @param id * Plugin identifier. * * @return Plugin container if the item was found. */ @Nullable PluginContainer getContainer(@Nonnull String id) { LoadedPlugin plugin = plugins.get(id); if (plugin == null) return null; return plugin.getContainer(); } /** * @return Collection of plugin containers. */ @Nonnull Collection> plugins() { return Collections2.transform(plugins.values(), LoadedPlugin::getContainer); } /** * @param id * Plugin identifier. * * @return Iterator of dependency classloaders. */ @Nonnull Iterator getDependencyClassloaders(@Nonnull String id) { var loaded = plugins.get(id); if (loaded == null) { return Collections.emptyIterator(); } return Iterators.transform(loaded.getDependencies().iterator(), input -> ((PluginClassLoaderImpl) input.getContainer().plugin().getClass().getClassLoader())); } /** * Enables the given plugin, initializing if necessary. * * @param loadedPlugin * Plugin to enable. * * @throws PluginException * If the plugin could not be initialized or enabled. */ @SuppressWarnings({"rawtypes", "unchecked"}) private void enable(@Nonnull LoadedPlugin loadedPlugin) throws PluginException { // Enable dependent plugins for (LoadedPlugin dependency : loadedPlugin.getDependencies()) enable(dependency); // Check if the plugin is already initialized. PluginContainerImpl container = loadedPlugin.getContainer(); Plugin plugin = container.plugin; if (plugin != null) return; // Already initialized, skip. // Initialize and enable the plugin. try { Class pluginClass = (Class) container.classLoader.lookupClass(container.preparedPlugin.pluginClassName()); plugin = classAllocator.instance(pluginClass); plugin.onEnable(); container.plugin = plugin; } catch (Throwable t) { throw new PluginException(t); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/PluginId.java ================================================ package software.coley.recaf.services.plugin; record PluginId(String id) {} ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/PluginInfo.java ================================================ package software.coley.recaf.services.plugin; import jakarta.annotation.Nonnull; import software.coley.recaf.plugin.Plugin; import software.coley.recaf.plugin.PluginInformation; import java.util.Set; /** * Object containing necessary information about a plugin. * * @param id ID of the plugin. * @param name Name of the plugin. * @param version Plugin version. * @param author Author of the plugin. * @param description Plugin description. * @param dependencies Plugin dependencies. * @param softDependencies Plugin soft dependencies. * @author xDark * @see PluginInformation Annotation containing this information applied to {@link Plugin} implementations. */ public record PluginInfo( @Nonnull String id, @Nonnull String name, @Nonnull String version, @Nonnull String author, @Nonnull String description, @Nonnull Set dependencies, @Nonnull Set softDependencies ) { @Nonnull public static PluginInfo empty() { return new PluginInfo("", "", "", "", "", Set.of(), Set.of()); } @Nonnull public PluginInfo withId(@Nonnull String id) { return new PluginInfo(id, name, version, author, description, dependencies, softDependencies); } @Nonnull public PluginInfo withName(@Nonnull String name) { return new PluginInfo(id, name, version, author, description, dependencies, softDependencies); } @Nonnull public PluginInfo withVersion(@Nonnull String version) { return new PluginInfo(id, name, version, author, description, dependencies, softDependencies); } @Nonnull public PluginInfo withAuthor(@Nonnull String author) { return new PluginInfo(id, name, version, author, description, dependencies, softDependencies); } @Nonnull public PluginInfo withDescription(@Nonnull String description) { return new PluginInfo(id, name, version, author, description, dependencies, softDependencies); } @Nonnull public PluginInfo withDependencies(@Nonnull Set dependencies) { return new PluginInfo(id, name, version, author, description, dependencies, softDependencies); } @Nonnull public PluginInfo withSoftDependencies(@Nonnull Set softDependencies) { return new PluginInfo(id, name, version, author, description, dependencies, softDependencies); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/PluginLoader.java ================================================ package software.coley.recaf.services.plugin; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.services.plugin.PluginException; import software.coley.recaf.services.plugin.PreparedPlugin; import software.coley.recaf.util.io.ByteSource; /** * The plugin loader is responsible for loading plugins from different sources. * * @author xDark */ public interface PluginLoader { /** * @param source Content to read from. * @return Prepared plugin or {@code null}, if source is not supported. * @throws PluginException When plugin cannot be prepared. */ @Nullable PreparedPlugin prepare(@Nonnull ByteSource source) throws PluginException; } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/PluginManager.java ================================================ package software.coley.recaf.services.plugin; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.plugin.*; import software.coley.recaf.services.Service; import software.coley.recaf.services.plugin.discovery.PluginDiscoverer; import java.util.Collection; import java.util.stream.Collectors; /** * Outline of a plugin manager. * * @author xDark */ public interface PluginManager extends Service { String SERVICE_ID = "plugin-manager"; /** * @return Allocator to use for creating plugin instances. */ @Nonnull ClassAllocator getAllocator(); /** * @param id * ID of the plugin. * @param * Container plugin type. * * @return Plugin by its ID or {@code null}, if not found. */ @Nullable PluginContainer getPlugin(@Nonnull String id); /** * @return Collection of plugins. */ @Nonnull Collection> getPlugins(); /** * @param type * Some type to look for. * @param * Requested type. * * @return Collection of plugins of the given type. */ @Nonnull @SuppressWarnings("unchecked") default Collection getPluginsOfType(@Nonnull Class type) { return (Collection) getPlugins().stream() .map(PluginContainer::plugin) .filter(plugin -> type.isAssignableFrom(plugin.getClass())) .collect(Collectors.toList()); } /** * Checks if a plugin is loaded. * * @param id * ID of plugin to check for. * * @return {@code true} if the plugin has been registered/loaded by this manager. */ boolean isPluginLoaded(@Nonnull String id); /** * Registers new loader. * * @param loader * {@link PluginLoader} to register. */ void registerLoader(@Nonnull PluginLoader loader); /** * @param discoverer * Plugin discoverer. * * @return Loaded plugins. * * @throws PluginException * If plugins fail to load. */ @Nonnull Collection> loadPlugins(@Nonnull PluginDiscoverer discoverer) throws PluginException; /** * @param id * ID of the plugin to be unloaded. * * @return Plugin unload action. * * @throws IllegalStateException * If plugin to be unloaded was not found. * @see PluginUnloader */ @Nonnull PluginUnloader unloaderFor(@Nonnull String id); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/PluginManagerConfig.java ================================================ package software.coley.recaf.services.plugin; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.observables.ObservableBoolean; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.BasicConfigValue; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link PluginManager}. * * @author Matt Coley */ @ApplicationScoped public class PluginManagerConfig extends BasicConfigContainer implements ServiceConfig { private final ObservableBoolean scanOnStartup = new ObservableBoolean(true); @Inject public PluginManagerConfig() { super(ConfigGroups.SERVICE_PLUGIN, PluginManager.SERVICE_ID + CONFIG_SUFFIX); addValue(new BasicConfigValue<>("scan-on-start", boolean.class, scanOnStartup)); } /** * @return {@code true} when local plugins should be scanned when the plugin manager implementation initializes. * {@code false} to disable local automatic plugin loading. */ public boolean doScanOnStartup() { return scanOnStartup.getValue(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/PluginSource.java ================================================ package software.coley.recaf.services.plugin; import jakarta.annotation.Nullable; import software.coley.recaf.util.io.ByteSource; /** * A functional mapping of internal paths (Like paths in a ZIP file) to the contents of the plugin. * * @author xDark */ public interface PluginSource { /** * @param name * Resource path name. * * @return Resource content or {@code null}, if not found. */ @Nullable ByteSource findResource(String name); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/PluginUnloader.java ================================================ package software.coley.recaf.services.plugin; import jakarta.annotation.Nonnull; import java.util.stream.Stream; /** * Plugin unload action. * * @author xDark */ public interface PluginUnloader { /** * Unloads the plugins. * * @throws PluginException * If any of the plugins failed to unload. */ void commit() throws PluginException; /** * @return Plugin to be unloaded. */ @Nonnull PluginInfo unloadingPlugin(); /** * @return A collection of plugins that depend on the plugin to be unloaded. * * @apiNote These plugins will also get unloaded. */ @Nonnull Stream dependants(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/PreparedPlugin.java ================================================ package software.coley.recaf.services.plugin; import jakarta.annotation.Nonnull; import software.coley.recaf.services.plugin.discovery.DiscoveredPluginSource; import software.coley.recaf.services.plugin.discovery.PluginDiscoverer; /** * Prepared plugin. Used as an intermediate step in plugin loading between {@link DiscoveredPluginSource} * and {@link PluginContainer}. * * @author xDark * @see BasicPluginManager#loadPlugins(PluginDiscoverer) */ public interface PreparedPlugin { /** * @return Plugin information. */ @Nonnull PluginInfo info(); /** * @return Plugin source. */ @Nonnull PluginSource pluginSource(); /** * @return Plugin class name. */ @Nonnull String pluginClassName(); /** * Called if {@link PluginManager} rejects this plugin. * * @throws PluginException * If any exception occurs. */ void reject() throws PluginException; } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/discovery/DirectoryPluginDiscoverer.java ================================================ package software.coley.recaf.services.plugin.discovery; import jakarta.annotation.Nonnull; import software.coley.recaf.services.plugin.PluginException; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.stream.Stream; /** * A plugin discoverer that searches a directory for plugin sources. * Subdirectories are not searched. * * @author xDark */ public final class DirectoryPluginDiscoverer extends PathPluginDiscoverer { private final Path directory; /** * @param directory Directory to iterate over for plugins. */ public DirectoryPluginDiscoverer(@Nonnull Path directory) { this.directory = directory; } @Nonnull @Override protected Stream stream() throws PluginException { try { return Files.find(directory, 1, (path, attributes) -> attributes.isRegularFile() && path.toString().toLowerCase().endsWith(".jar")); } catch (IOException ex) { throw new PluginException(ex); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/discovery/DiscoveredPluginSource.java ================================================ package software.coley.recaf.services.plugin.discovery; import jakarta.annotation.Nonnull; import software.coley.recaf.util.io.ByteSource; /** * Provider for a {@link ByteSource} to point to a newly discovered plugin file. * * @author xDark */ public interface DiscoveredPluginSource { /** * @return Source to load from the plugin file. */ @Nonnull ByteSource source(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/discovery/PathPluginDiscoverer.java ================================================ package software.coley.recaf.services.plugin.discovery; import jakarta.annotation.Nonnull; import software.coley.recaf.services.plugin.PluginException; import software.coley.recaf.util.io.ByteSources; import java.nio.file.Path; import java.util.List; import java.util.stream.Stream; /** * A common base for {@link Path} backed plugin discoverers. * * @author xDark */ public abstract class PathPluginDiscoverer implements PluginDiscoverer { @Nonnull @Override public final List findSources() throws PluginException { try (Stream s = stream()) { return s.map(path -> (DiscoveredPluginSource) () -> ByteSources.forPath(path)) .toList(); } } /** * @return A stream of paths to treat as plugin sources. * * @throws PluginException * If discovery fails. */ @Nonnull protected abstract Stream stream() throws PluginException; } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/discovery/PluginDiscoverer.java ================================================ package software.coley.recaf.services.plugin.discovery; import jakarta.annotation.Nonnull; import software.coley.recaf.services.plugin.PluginException; import java.util.List; /** * Plugin source discoverer. * * @author xDark */ public interface PluginDiscoverer { /** * @return A list of discovered plugin sources. * * @throws PluginException * If discovery fails. */ @Nonnull List findSources() throws PluginException; } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/zip/ZipArchiveView.java ================================================ package software.coley.recaf.services.plugin.zip; import jakarta.annotation.Nonnull; import software.coley.lljzip.format.model.CentralDirectoryFileHeader; import software.coley.lljzip.format.model.LocalFileHeader; import software.coley.lljzip.format.model.ZipArchive; import java.io.IOException; import java.util.HashMap; import java.util.List; import java.util.Map; /** * Exposes a {@link ZipArchive}'s contents as a {@code Map}. * * @author xDark */ final class ZipArchiveView implements AutoCloseable { private final ZipArchive archive; // keep alive private final Map names; private volatile boolean closed; ZipArchiveView(@Nonnull ZipArchive archive) { this.archive = archive; Map names; // Populate view from authoritative central directory entries if possible. List centralDirectories = archive.getCentralDirectories(); if (!centralDirectories.isEmpty()) { names = HashMap.newHashMap(centralDirectories.size()); for (CentralDirectoryFileHeader cdf : centralDirectories) { String name = cdf.getFileNameAsString(); names.putIfAbsent(name, cdf.getLinkedFileHeader()); } } else { // Fall back to local file entries id central directory entries do not exist. List localFiles = archive.getLocalFiles(); names = HashMap.newHashMap(localFiles.size()); for (LocalFileHeader localFile : localFiles) { names.putIfAbsent(localFile.getFileNameAsString(), localFile); } } this.names = names; } @Override public void close() throws IOException { if (closed) return; synchronized (this) { if (closed) return; closed = true; } // Try-with to auto-close the archive when complete. try (archive) { names.clear(); } } /** * @return {@code true} when the backing archive is released. */ public boolean isClosed() { return closed; } /** * @return Backing archive. */ @Nonnull public ZipArchive getArchive() { return archive; } /** * @return Archive entries as a map of internal paths. */ @Nonnull public Map getEntries() { return names; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/zip/ZipPluginLoader.java ================================================ package software.coley.recaf.services.plugin.zip; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.objectweb.asm.Type; import software.coley.cafedude.InvalidClassException; import software.coley.cafedude.classfile.ClassFile; import software.coley.cafedude.classfile.annotation.Annotation; import software.coley.cafedude.classfile.annotation.ArrayElementValue; import software.coley.cafedude.classfile.annotation.ElementValue; import software.coley.cafedude.classfile.annotation.Utf8ElementValue; import software.coley.cafedude.classfile.attribute.AnnotationsAttribute; import software.coley.cafedude.classfile.attribute.Attribute; import software.coley.cafedude.io.ClassFileReader; import software.coley.collections.Unchecked; import software.coley.lljzip.ZipIO; import software.coley.lljzip.format.model.ZipArchive; import software.coley.recaf.plugin.*; import software.coley.recaf.services.plugin.PluginException; import software.coley.recaf.services.plugin.PluginInfo; import software.coley.recaf.services.plugin.PluginLoader; import software.coley.recaf.services.plugin.PreparedPlugin; import software.coley.recaf.util.IOUtil; import software.coley.recaf.util.io.ByteSource; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; /** * Zip plugin loader. * * @author xDark */ public final class ZipPluginLoader implements PluginLoader { private static final String PLUGIN_INFORMATION_DESC = Type.getDescriptor(PluginInformation.class); public static final String SERVICE_PATH = servicePath(); @Nullable @Override public PreparedPlugin prepare(@Nonnull ByteSource source) throws PluginException { ZipArchive zip; try { zip = ZipIO.readJvm(source.mmap()); } catch (IOException ex) { return null; } ZipArchiveView archiveView; try { archiveView = new ZipArchiveView(zip); } catch (Throwable t) { try { zip.close(); } catch (IOException ignored) {} throw t; } var zs = new ZipSource(archiveView); try { ByteSource resPluginImplementation = zs.findResource(SERVICE_PATH); if (resPluginImplementation == null) { throw new PluginException("Cannot find %s resource".formatted(SERVICE_PATH)); } // Cannot use ServiceLoader here, it will attempt to instantiate the plugin, // which is what we *don't* want at this point. String pluginClassName; try (BufferedReader reader = IOUtil.toBufferedReader(resPluginImplementation.openStream())) { pluginClassName = reader.readLine(); { String extraLine; while ((extraLine = reader.readLine()) != null) { if (!extraLine.isEmpty()) throw new PluginException("Extra line %s".formatted(extraLine)); } } } // Find plugin class. ByteSource resPluginClass = zs.findResource(pluginClassName.replace('.', '/') + ".class"); if (resPluginClass == null) { throw new PluginException("Plugin class %s doesn't exist".formatted(pluginClassName)); } // Find @PluginInformation annotation. ClassFile cf; try (InputStream in = resPluginClass.openStream()) { ClassFileReader reader = new ClassFileReader(); cf = reader.read(in.readAllBytes()); } catch (InvalidClassException ex) { throw new PluginException(ex); } PluginInfo info = null; for (Attribute attr : cf.getAttributes()) { if (!(attr instanceof AnnotationsAttribute annotations)) continue; if (!annotations.isVisible()) continue; for (Annotation annotation : annotations.getAnnotations()) { if (!PLUGIN_INFORMATION_DESC.equals(annotation.getType().getText())) continue; // Collect information. info = parsePluginInfo(annotation); break; } } if (info == null) { throw new PluginException("Missing @PluginInformation annotation"); } PreparedPlugin preparedPlugin = new ZipPreparedPlugin(info, pluginClassName, zs); zs = null; return preparedPlugin; } catch (IOException ex) { throw new PluginException(ex); } finally { if (zs != null) { try { zs.close(); } catch (IOException ignored) {} } } } @Nonnull private static PluginInfo parsePluginInfo(@Nonnull Annotation annotation) throws PluginException { PluginInfo info = PluginInfo.empty(); for (var e : annotation.getValues().entrySet()) { String name = e.getKey().getText(); ElementValue value = e.getValue(); info = switch (name) { case "id" -> info.withId(string(value)); case "name" -> info.withName(string(value)); case "version" -> info.withVersion(string(value)); case "author" -> info.withAuthor(string(value)); case "description" -> info.withDescription(string(value)); case "dependencies" -> info.withDependencies(stringSet(value)); case "softDependencies" -> info.withSoftDependencies(stringSet(value)); default -> info; }; } return info; } @Nonnull private static String servicePath() { return "META-INF/services/%s".formatted(Plugin.class.getName()); } @SafeVarargs @SuppressWarnings("unchecked") private static R extractValue(@Nonnull ElementValue value, Function extractor, V... typeHint) throws PluginException { Class type = typeHint.getClass().getComponentType(); V v; try { v = ((Class) type).cast(value); } catch (ClassCastException ex) { throw new PluginException("%s is not an instance of %s".formatted(value, type.getSimpleName())); } return extractor.apply(v); } @Nonnull private static String string(@Nonnull ElementValue value) throws PluginException { return extractValue(value, (Utf8ElementValue elem) -> elem.getValue().getText()); } @Nonnull private static Set stringSet(@Nonnull ElementValue value) throws PluginException { return extractValue(value, (ArrayElementValue array) -> array .getArray() .stream() .map(Unchecked.function(ZipPluginLoader::string)) .collect(Collectors.toUnmodifiableSet())); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/zip/ZipPreparedPlugin.java ================================================ package software.coley.recaf.services.plugin.zip; import jakarta.annotation.Nonnull; import software.coley.recaf.services.plugin.PluginException; import software.coley.recaf.services.plugin.PluginInfo; import software.coley.recaf.services.plugin.PluginSource; import software.coley.recaf.services.plugin.PreparedPlugin; import java.io.IOException; /** * ZIP backed prepared plugin implementation. * * @author xDark */ final class ZipPreparedPlugin implements PreparedPlugin { private final PluginInfo pluginInfo; private final String pluginClassName; private final ZipSource classLoader; ZipPreparedPlugin(@Nonnull PluginInfo pluginInfo, @Nonnull String pluginClassName, @Nonnull ZipSource classLoader) { this.pluginInfo = pluginInfo; this.pluginClassName = pluginClassName; this.classLoader = classLoader; } @Nonnull @Override public PluginInfo info() { return pluginInfo; } @Nonnull @Override public PluginSource pluginSource() { return classLoader; } @Nonnull @Override public String pluginClassName() { return pluginClassName; } @Override public void reject() throws PluginException { try { classLoader.close(); } catch (IOException ex) { throw new PluginException(ex); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/plugin/zip/ZipSource.java ================================================ package software.coley.recaf.services.plugin.zip; import jakarta.annotation.Nonnull; import software.coley.lljzip.format.model.LocalFileHeader; import software.coley.recaf.services.plugin.PluginSource; import software.coley.recaf.util.io.ByteSource; import software.coley.recaf.util.io.LocalFileHeaderSource; import java.io.IOException; /** * ZIP backed plugin source. * * @author xDark */ final class ZipSource implements PluginSource, AutoCloseable { private final ZipArchiveView archiveView; ZipSource(@Nonnull ZipArchiveView archiveView) { this.archiveView = archiveView; } @Override public ByteSource findResource(String name) { ZipArchiveView archiveView = this.archiveView; if (archiveView.isClosed()) return null; synchronized (this) { if (archiveView.isClosed()) return null; LocalFileHeader file = archiveView.getEntries().get(name); if (file == null) return null; return new LocalFileHeaderSource(file); } } @Override public void close() throws IOException { archiveView.close(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/script/GenerateResult.java ================================================ package software.coley.recaf.services.script; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.services.compile.CompilerDiagnostic; import java.util.List; /** * Wrapper for results of {@link ScriptEngine#compile(String)} calls. * * @param cls * Compiled class reference. May be {@code null} when compilation failed. * @param diagnostics * Compiler diagnostic messages. * * @author Matt Coley */ public record GenerateResult(@Nullable Class cls, @Nonnull List diagnostics) { /** * @return {@code true} when compilation was a success. */ public boolean wasSuccess() { return cls != null; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/script/JavacScriptEngine.java ================================================ package software.coley.recaf.services.script; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import regexodus.Matcher; import software.coley.recaf.analytics.logging.DebuggingLogger; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.services.compile.*; import software.coley.recaf.services.plugin.CdiClassAllocator; import software.coley.recaf.util.ClassDefiner; import software.coley.recaf.util.ReflectUtil; import software.coley.recaf.util.RegexUtil; import software.coley.recaf.util.StringUtil; import software.coley.recaf.util.threading.ThreadPoolFactory; import java.lang.reflect.Method; import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.stream.Collectors; /** * Basic implementation of {@link ScriptEngine} using {@link JavacCompiler}. * * @author Matt Coley */ @ApplicationScoped public class JavacScriptEngine implements ScriptEngine { private static final DebuggingLogger logger = Logging.get(JavacScriptEngine.class); private static final String SCRIPT_PACKAGE_NAME = "software.coley.recaf.generated"; private static final String PATTERN_PACKAGE = "package ([\\w\\.\\*]+);?"; private static final String PATTERN_IMPORT = "import ([\\w\\.\\*]+);?"; private static final String PATTERN_CLASS_NAME = "(?<=class)\\s+(\\w+)\\s+(?:implements|extends|\\{)"; private static final List DEFAULT_IMPORTS = Arrays.asList( "java.io.*", "java.nio.file.*", "java.util.*", "software.coley.recaf.*", "software.coley.recaf.analytics.logging.*", "software.coley.recaf.info.*", "software.coley.recaf.info.annotation.*", "software.coley.recaf.info.builder.*", "software.coley.recaf.info.member.*", "software.coley.recaf.info.properties.*", "software.coley.recaf.services.*", // "software.coley.recaf.services.assemble.*", "software.coley.recaf.services.attach.*", "software.coley.recaf.services.callgraph.*", "software.coley.recaf.services.compile.*", "software.coley.recaf.services.config.*", "software.coley.recaf.services.decompile.*", "software.coley.recaf.services.file.*", "software.coley.recaf.services.inheritance.*", "software.coley.recaf.services.mapping.*", "software.coley.recaf.services.plugin.*", "software.coley.recaf.services.script.*", "software.coley.recaf.services.search.*", "software.coley.recaf.services.workspace.*", "software.coley.recaf.services.workspace.io.*", "software.coley.recaf.workspace.model.*", "software.coley.recaf.workspace.model.bundle.*", // "software.coley.recaf.services.ssvm.*", "software.coley.recaf.util.*", "software.coley.recaf.util.android.*", "software.coley.recaf.util.io.*", "software.coley.recaf.util.threading.*", "software.coley.recaf.util.visitors.*", "org.objectweb.asm.*", "org.objectweb.asm.tree.*", "jakarta.annotation.*", "jakarta.enterprise.context.*", "jakarta.inject.*", "org.slf4j.Logger" ); private final Map generateResultMap = new HashMap<>(); private final ExecutorService compileAndRunPool = ThreadPoolFactory.newSingleThreadExecutor("script-loader"); private final JavacCompiler compiler; private final CdiClassAllocator allocator; private final ScriptEngineConfig config; @Inject public JavacScriptEngine(JavacCompiler compiler, CdiClassAllocator allocator, ScriptEngineConfig config) { this.compiler = compiler; this.allocator = allocator; this.config = config; } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public ScriptEngineConfig getServiceConfig() { return config; } @Nonnull @Override public CompletableFuture run(@Nonnull String script) { return CompletableFuture.supplyAsync(() -> handleExecute(script), compileAndRunPool); } @Nonnull @Override public CompletableFuture compile(@Nonnull String scriptSource) { return CompletableFuture.supplyAsync(() -> generate(scriptSource), compileAndRunPool); } /** * Compiles and executes the script. * If the same script has already been compiled previously, the prior class reference will be used * to reduce duplicate compilations. * * @param script * Script to execute. * * @return Result of script execution. */ @Nonnull private ScriptResult handleExecute(@Nonnull String script) { GenerateResult result = generate(script); if (result.cls() != null) { try { logger.debugging(l -> l.info("Allocating script instance")); Object instance = allocator.instance(result.cls()); Method run = ReflectUtil.getDeclaredMethod(instance.getClass(), "run"); run.setAccessible(true); run.invoke(instance); logger.debugging(l -> l.info("Successfully ran script")); return new ScriptResult(result.diagnostics()); } catch (Exception ex) { logger.error("Failed to execute script", ex); return new ScriptResult(result.diagnostics(), ex); } } else { logger.error("Failed to compile script"); return new ScriptResult(result.diagnostics()); } } /** * Maps an input script to a full Java source file, and compiles it. * Delegates to either: *

    *
  • {@link #generateScriptClass(String, String)}
  • *
  • {@link #generateStandardClass(String)}
  • *
* * @param script * Initial source of the script. * * @return Compiler result wrapper containing the loaded class reference. */ private GenerateResult generate(@Nonnull String script) { int hash = script.hashCode(); GenerateResult result; if (RegexUtil.matchesAny(PATTERN_CLASS_NAME, script)) { logger.debugging(l -> l.info("Compiling script as class")); result = generateResultMap.computeIfAbsent(hash, n -> generateStandardClass(script)); } else { logger.debugging(l -> l.info("Compiling script as function")); String className = "Script" + Math.abs(hash); result = generateResultMap.computeIfAbsent(hash, n -> generateScriptClass(className, script)); } return result; } /** * Used when the script contains a class definition in itself. * Adds the default script package name, if no package is defined. * * @param source * Initial source of the script. * * @return Compiler result wrapper containing the loaded class reference. */ @Nonnull private GenerateResult generateStandardClass(@Nonnull String source) { String originalSource = source; // Extract package name String packageName = SCRIPT_PACKAGE_NAME; Matcher matcher = RegexUtil.getMatcher(PATTERN_PACKAGE, source); if (matcher.find()) packageName = matcher.group(1); else source = "package " + packageName + ";\n" + source; // Add default imports String imports = "\nimport " + String.join(";\nimport ", DEFAULT_IMPORTS) + ";\n"; source = StringUtil.insert(source, source.indexOf(packageName + ";") + packageName.length() + 1, imports); // Normalize package name packageName = packageName.replace('.', '/'); // Extract class name String className; matcher = RegexUtil.getMatcher(PATTERN_CLASS_NAME, source); if (matcher.find()) { String originalName = matcher.group(1); String modifiedName = originalName + Math.abs(source.hashCode()); className = packageName + "/" + modifiedName; // Replace name in script // - Class definition // - Constructors source = StringUtil.replaceRange(source, matcher.start(1), matcher.end(1), modifiedName); source = source.replace(" " + originalName + "(", " " + modifiedName + "("); source = source.replace("\t" + originalName + "(", "\t" + modifiedName + "("); } else { return new GenerateResult(null, List.of( new CompilerDiagnostic(-1, -1, 0, "Could not determine name of class", CompilerDiagnostic.Level.ERROR))); } // Compile the class return generate(className, originalSource, source); } /** * Used when the script immediately starts with the code. * This will wrap that content in a basic class. * * @param className * Name of the script class. * @param script * Initial source of the script. * * @return Compiler result wrapper containing the loaded class reference. */ @Nonnull private GenerateResult generateScriptClass(@Nonnull String className, @Nonnull String script) { String originalSource = script; Set imports = new HashSet<>(DEFAULT_IMPORTS); Matcher matcher = RegexUtil.getMatcher(PATTERN_IMPORT, script); while (matcher.find()) { // Record import statement String importIdentifier = matcher.group(1); imports.add(importIdentifier); // Replace text with spaces to maintain script character offsets String importMatch = script.substring(matcher.start(), matcher.end()); script = script.replace(importMatch, " ".repeat(importMatch.length())); } // Create code (just a basic class with a static 'run' method) StringBuilder code = new StringBuilder( "@Dependent public class " + className + " implements Runnable, Opcodes { " + "private static final Logger log = Logging.get(\"script\"); " + "Workspace workspace; " + "@Inject " + className +"(Workspace workspace) { this.workspace = workspace; } " + "public void run() {\n" + script + "\n" + "}" + "}"); for (String imp : imports) code.insert(0, "import " + imp + "; "); code.insert(0, "package " + SCRIPT_PACKAGE_NAME + "; "); className = SCRIPT_PACKAGE_NAME.replace('.', '/') + "/" + className; // Compile the class return generate(className, originalSource, code.toString()); } /** * @param className * Name of the script class. * @param originalSource * Original source provided by the user. * @param compileSource * Full source of the script to pass to the compiler. * * @return Compiler result wrapper containing the loaded class reference. */ @Nonnull private GenerateResult generate(@Nonnull String className, @Nonnull String originalSource, @Nonnull String compileSource) { JavacArguments args = new JavacArgumentsBuilder() .withClassName(className) .withClassSource(compileSource) .build(); CompilerResult result = compiler.compile(args, null, null); if (result.wasSuccess()) { try { Map classes = result.getCompilations().entrySet().stream() .collect(Collectors.toMap(e -> e.getKey().replace('/', '.'), Map.Entry::getValue)); ClassDefiner definer = new ClassDefiner(classes); Class cls = definer.findClass(className.replace('/', '.')); return new GenerateResult(cls, mapDiagnostics(originalSource, compileSource, result.getDiagnostics())); } catch (Exception ex) { logger.error("Failed to define generated script class", ex); } } return new GenerateResult(null, mapDiagnostics(originalSource, compileSource, result.getDiagnostics())); } /** * @param originalSource * Original source provided by the user. * @param compileSource * Full source of the script to pass to the compiler. * @param diagnostics * Diagnostics to map position of. * * @return List of updated diagnostics. */ private List mapDiagnostics(@Nonnull String originalSource, @Nonnull String compileSource, @Nonnull List diagnostics) { int syntheticLineCount = StringUtil.count("\n", StringUtil.cutOffAtFirst(compileSource, originalSource)); return diagnostics.stream() .map(d -> d.withLine(d.line() - syntheticLineCount)) .toList(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/script/ScriptEngine.java ================================================ package software.coley.recaf.services.script; import jakarta.annotation.Nonnull; import software.coley.recaf.services.Service; import java.util.concurrent.CompletableFuture; /** * Outline for script execution and compilation. * * @author Matt Coley */ public interface ScriptEngine extends Service { String SERVICE_ID = "script-engine"; /** * @param scriptSource * Script source to execute. * * @return Future of script execution. */ @Nonnull CompletableFuture run(@Nonnull String scriptSource); /** * @param scriptSource * Script source to compile. * * @return Future of script compilation. */ @Nonnull CompletableFuture compile(@Nonnull String scriptSource); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/script/ScriptEngineConfig.java ================================================ package software.coley.recaf.services.script; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link ScriptEngine}. * * @author Matt Coley */ @ApplicationScoped public class ScriptEngineConfig extends BasicConfigContainer implements ServiceConfig { @Inject public ScriptEngineConfig() { super(ConfigGroups.SERVICE_PLUGIN, ScriptEngine.SERVICE_ID + CONFIG_SUFFIX); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/script/ScriptFile.java ================================================ package software.coley.recaf.services.script; import jakarta.annotation.Nonnull; import java.nio.file.Path; import java.util.Map; import java.util.concurrent.CompletableFuture; /** * Wrapper of a script path, with meta-data. * * @param path * Path to script file. * @param tags * Script's metadata tags. * * @author Matt Coley */ public record ScriptFile(@Nonnull Path path, @Nonnull String source, @Nonnull Map tags) implements Comparable { public static final String KEY_NAME = "name"; public static final String KEY_DESCRIPTION = "description"; public static final String KEY_VERSION = "version"; public static final String KEY_AUTHOR = "author"; /** * Executes the script's content in the given engine. * * @param engine * Engine to execute with. * * @return Script execution future. */ @Nonnull public CompletableFuture execute(@Nonnull ScriptEngine engine) { return engine.run(source()); } /** * @return Script name. */ @Nonnull public String name() { return getTagValue(KEY_NAME); } /** * @return Script description. */ @Nonnull public String description() { return getTagValue(KEY_DESCRIPTION); } /** * @return Script version. */ @Nonnull public String version() { return getTagValue(KEY_VERSION); } /** * @return Script author. */ @Nonnull public String author() { return getTagValue(KEY_AUTHOR); } /** * @param tag * Name of tag. * * @return Value of tag, or empty string if no tag exists. */ @Nonnull public String getTagValue(@Nonnull String tag) { return tags.getOrDefault(tag, ""); } @Override public int compareTo(ScriptFile o) { return path().compareTo(o.path()); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/script/ScriptManager.java ================================================ package software.coley.recaf.services.script; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import regexodus.Matcher; import org.slf4j.Logger; import software.coley.observables.ObservableBoolean; import software.coley.observables.ObservableCollection; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.services.Service; import software.coley.recaf.util.RegexUtil; import software.coley.recaf.util.StringUtil; import software.coley.recaf.util.threading.ThreadPoolFactory; import java.io.IOException; import java.nio.file.*; import java.util.*; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; /** * Manages local script files. * * @author Matt Coley * @see ScriptEngine Executor for scripts. * @see ScriptFile Local script file type. */ @ApplicationScoped public class ScriptManager implements Service { public static final String SERVICE_ID = "script-manager"; private static final String TAG_PATTERN = "//(\\s+)?@({key}\\S+)\\s+({value}.+)"; private static final Logger logger = Logging.get(ScriptManager.class); private final ObservableCollection> scriptFiles = new ObservableCollection<>(ArrayList::new); private final ScriptManagerConfig config; private final WatchTask watchTask; @Inject public ScriptManager(@Nonnull ScriptManagerConfig config) { this.config = config; watchTask = new WatchTask(); // Start watching files in scripts directory ObservableBoolean fileWatching = config.getFileWatching(); if (fileWatching.getValue()) watchTask.start(); // When the watch flag is re-enabled, re-submit the watch-pool task. fileWatching.addChangeListener((ob, old, cur) -> { if (cur) watchTask.start(); else watchTask.stop(); }); } /** * @param path * Path to script file. * * @return Wrapper of script. * * @throws IOException * When the script file cannot be read from. */ @Nonnull public ScriptFile read(@Nonnull Path path) throws IOException { String text = Files.readString(path); // Parse tags from beginning of file Map tags = new HashMap<>(); int metaEnd = Math.max(0, text.indexOf("==/Metadata==")); int lineMetaEnd = StringUtil.count("\n", text.substring(0, metaEnd)); text.lines().limit(lineMetaEnd).forEach(line -> { if (line.startsWith("//")) { Matcher matcher = RegexUtil.getMatcher(TAG_PATTERN, line); if (matcher.matches()) { String key = matcher.group("key").toLowerCase(); String value = matcher.group("value"); tags.put(key, value); } } }); return new ScriptFile(path, text, tags); } /** * @param path * Path of file newly created. */ private void onScriptCreate(@Nonnull Path path) { try { logger.debug("Script created: {}", path); ScriptFile file = read(path); scriptFiles.add(file); } catch (IOException ex) { logger.error("Could not load script from path: {}", path, ex); } } /** * @param path * Path of file modified. */ private void onScriptUpdated(@Nonnull Path path) { try { // Read updated script content from path. ScriptFile updated = read(path); // Replace old file wrapper with new wrapper. // Only do so if they are not equal. There are some odd situations where you will get duplicate // file-watcher events on the same file even if the contents are not modified. if (scriptFiles.removeIf(file -> path.equals(file.path()) && !file.equals(updated))) scriptFiles.add(updated); } catch (IOException ex) { logger.error("Could not load script from path: {}", path, ex); } } /** * @param path * Path of file removed. */ private void onScriptRemoved(@Nonnull Path path) { logger.debug("Script removed: {}", path); scriptFiles.removeIf(file -> path.equals(file.path())); } /** * @return Collection of local available script files. */ @Nonnull public ObservableCollection> getScriptFiles() { return scriptFiles; } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public ScriptManagerConfig getServiceConfig() { return config; } /** * Wrapper to manage threaded watch service on the script directory. */ private class WatchTask { private final Path scriptsDirectory = config.getScriptsDirectory(); private final ExecutorService watchPool = ThreadPoolFactory.newSingleThreadExecutor(SERVICE_ID); private Future watchFuture; private WatchService watchService; private void start() { scanExisting(); // Only start a new thread when the old one is complete, or if no prior one exists. if (watchFuture == null || watchFuture.isDone()) { logger.debug("Starting script directory watch task"); watchFuture = watchPool.submit(this::watch); } } private void stop() { // Calling 'close()' on the service will make the loop on the service event 'take()' break. if (watchService != null) { try { logger.debug("Stopping script directory watch task"); watchService.close(); } catch (IOException ex) { logger.error("Failed to stop script directory watch service"); } watchFuture.cancel(true); watchService = null; } } private void scanExisting() { try { // Walk the directory, create or update scripts that exist. Set scriptsCopy = new HashSet<>(scriptFiles); Files.walk(scriptsDirectory).forEach(path -> { if (Files.isRegularFile(path)) { Optional matchingScript = scriptsCopy.stream() .filter(script -> script.path().equals(path)) .findFirst(); if (matchingScript.isPresent()) { scriptsCopy.remove(matchingScript.get()); onScriptUpdated(path); } else { onScriptCreate(path); } } }); // Any remaining items in the set do not exist in the directory, so we remove them. scriptFiles.removeAll(scriptsCopy); } catch (IOException ex) { logger.error("Failed to scan existing scripts in script directory", ex); } } private void watch() { try (WatchService watchService = FileSystems.getDefault().newWatchService()) { this.watchService = watchService; WatchKey watchKey = scriptsDirectory.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_MODIFY, StandardWatchEventKinds.ENTRY_DELETE); WatchKey key; while ((key = watchService.take()) != null) { for (WatchEvent event : key.pollEvents()) { Path eventPath = scriptsDirectory.resolve((Path) event.context()); WatchEvent.Kind kind = event.kind(); if (Files.isRegularFile(eventPath)) { try { // We are only interested in 'ENTRY_MODIFY' events since that is when file content is written. // A script file created via 'ENTRY_CREATE' will always be empty, so reading from it at // that point would be useless. if (kind == StandardWatchEventKinds.ENTRY_MODIFY) { Set scriptsCopy = new HashSet<>(scriptFiles); Optional matchingScript = scriptsCopy.stream() .filter(script -> script.path().equals(eventPath)) .findFirst(); if (matchingScript.isPresent()) { scriptsCopy.remove(matchingScript.get()); onScriptUpdated(eventPath); } else { onScriptCreate(eventPath); } } } catch (Throwable t) { logger.error("Unhandled exception updating available scripts", t); } } else if (kind == StandardWatchEventKinds.ENTRY_DELETE) { onScriptRemoved(eventPath); } } if (!key.reset()) logger.warn("Key was unregistered: {}", key); } watchKey.cancel(); logger.info("Stopped watching script directory for updates"); } catch (IOException ex) { logger.error("IO exception when handling file watch on scripts directory", ex); } catch (InterruptedException ex) { logger.error("File watch on scripts directory was interrupted", ex); } catch (ClosedWatchServiceException ignored) { // expected when watch service is closed } } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/script/ScriptManagerConfig.java ================================================ package software.coley.recaf.services.script; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.observables.ObservableBoolean; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.BasicConfigValue; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; import software.coley.recaf.services.file.RecafDirectoriesConfig; import java.nio.file.Path; /** * Config for {@link ScriptManager}. * * @author Matt Coley */ @ApplicationScoped public class ScriptManagerConfig extends BasicConfigContainer implements ServiceConfig { private final ObservableBoolean fileWatching = new ObservableBoolean(true); private final RecafDirectoriesConfig directories; @Inject public ScriptManagerConfig(RecafDirectoriesConfig directories) { super(ConfigGroups.SERVICE_PLUGIN, ScriptManager.SERVICE_ID + CONFIG_SUFFIX); this.directories = directories; addValue(new BasicConfigValue<>("file-watching", boolean.class, fileWatching)); } /** * @return Directory containing local scripts. */ @Nonnull public Path getScriptsDirectory() { return directories.getScriptsDirectory(); } /** * @return {@code true} to enable file watching in {@link ScriptManager} to automatically update available scripts. */ @Nonnull public ObservableBoolean getFileWatching() { return fileWatching; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/script/ScriptResult.java ================================================ package software.coley.recaf.services.script; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.services.compile.CompilerDiagnostic; import java.util.List; /** * Wrapper for results of {@link ScriptEngine#run(String)} calls. * * @author Matt Coley */ public class ScriptResult { private final List diagnostics; private final Throwable throwable; /** * @param diagnostics * Compiler error list. */ public ScriptResult(@Nonnull List diagnostics) { this(diagnostics, null); } /** * @param diagnostics * Compiler error list. * @param throwable * Runtime error value. */ public ScriptResult(@Nonnull List diagnostics, @Nullable Throwable throwable) { this.diagnostics = diagnostics; this.throwable = throwable; } /** * @return {@code true} when there were no compiler or runtime errors. */ public boolean wasSuccess() { return !wasCompileFailure() && !wasRuntimeError(); } /** * @return {@code true} when {@link #getCompileDiagnostics()} has content. */ public boolean wasCompileFailure() { return !getCompileDiagnostics().isEmpty(); } /** * @return {@code true} when {@link #getRuntimeThrowable()} is present. */ public boolean wasRuntimeError() { return throwable != null; } /** * @return List of compiler diagnostics. */ @Nonnull public List getCompileDiagnostics() { return diagnostics; } /** * @return Exception thrown when running the generated script method. */ @Nullable public Throwable getRuntimeThrowable() { return throwable; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/AndroidClassSearchVisitor.java ================================================ package software.coley.recaf.services.search; import jakarta.annotation.Nonnull; import software.coley.recaf.info.AndroidClassInfo; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.services.search.result.Results; /** * Visitor for {@link AndroidClassInfo} * * @author Matt Coley */ public interface AndroidClassSearchVisitor extends SearchVisitor { /** * Visits an Android class. * * @param resultSink * Consumer to feed result values into, typically populating a {@link Results} instance. * @param classPath * Path to class being visited. * @param classInfo * Class to visit. */ void visit(@Nonnull ResultSink resultSink, @Nonnull ClassPathNode classPath, @Nonnull AndroidClassInfo classInfo); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/CancellableSearchFeedback.java ================================================ package software.coley.recaf.services.search; /** * Feedback that allows cancelling a search. * * @author Matt Coley */ public class CancellableSearchFeedback implements SearchFeedback { private boolean canceled; /** * Mark search as cancelled. */ public void cancel() { canceled = true; } @Override public boolean hasRequestedCancellation() { return canceled; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/FileSearchVisitor.java ================================================ package software.coley.recaf.services.search; import jakarta.annotation.Nonnull; import software.coley.recaf.info.FileInfo; import software.coley.recaf.path.FilePathNode; import software.coley.recaf.services.search.result.Results; /** * Visitor for {@link FileInfo} * * @author Matt Coley */ public interface FileSearchVisitor extends SearchVisitor { /** * Visits a generic file. * * @param resultSink * Consumer to feed result values into, typically populating a {@link Results} instance. * @param filePath * Path to file being visited. * @param fileInfo * File to visit. */ void visit(@Nonnull ResultSink resultSink, @Nonnull FilePathNode filePath, @Nonnull FileInfo fileInfo); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/JvmClassSearchVisitor.java ================================================ package software.coley.recaf.services.search; import jakarta.annotation.Nonnull; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.services.search.result.Results; /** * Visitor for {@link JvmClassInfo} * * @author Matt Coley */ public interface JvmClassSearchVisitor extends SearchVisitor { /** * Visits an JVM class. * * @param resultSink * Consumer to feed result values into, typically populating a {@link Results} instance. * @param classPath * Path to class being visited. * @param classInfo * Class to visit. */ void visit(@Nonnull ResultSink resultSink, @Nonnull ClassPathNode classPath, @Nonnull JvmClassInfo classInfo); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/ResultSink.java ================================================ package software.coley.recaf.services.search; import jakarta.annotation.Nonnull; import software.coley.recaf.path.PathNode; /** * Outline of a sink for {@link SearchVisitor} implementations to feed data into. * * @author Matt Coley */ public interface ResultSink { /** * @param path * Path of found value. * @param value * Found value. */ void accept(@Nonnull PathNode path, @Nonnull Object value); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/SearchFeedback.java ================================================ package software.coley.recaf.services.search; import jakarta.annotation.Nonnull; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.FileInfo; import software.coley.recaf.services.search.result.Result; import software.coley.recaf.services.search.result.Results; /** * Outline of search feedback capabilities. Allows for: *
    *
  • In-progress search cancellation
  • *
  • Filter classes and files visited by the search
  • *
* * @author Matt Coley * @see CancellableSearchFeedback Basic cancellable implementation. */ public interface SearchFeedback { /** * Default implementation that runs searches to completion, without any filtering. */ SearchFeedback DEFAULT = new SearchFeedback() { }; /** * @return {@code true} to request {@link SearchService} stops handling input to end the search early. * {@code false} to continue the search. */ default boolean hasRequestedCancellation() { return false; } /** * Called before checking a given class's contents against some search query. * * @param cls * Class to consider for visitation. * * @return {@code true} to visit a class in search operations. * {@code false} to skip. */ default boolean doVisitClass(@Nonnull ClassInfo cls) { return true; } /** * Called before checking a given file's contents against some search query. * * @param file * File to consider for visitation. * * @return {@code true} to visit a file in search operations. * {@code false} to skip. */ default boolean doVisitFile(@Nonnull FileInfo file) { return true; } /** * Called when a search query finds a matching result. * * @param result * Result to consider. * * @return {@code true} to accept the result into the final {@link Results} collection. * {@code false} to drop it. */ default boolean doAcceptResult(@Nonnull Result result) { return true; } /** * Called when the search query completes. */ default void onCompletion() {} } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/SearchService.java ================================================ package software.coley.recaf.services.search; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.info.AndroidClassInfo; import software.coley.recaf.info.FileInfo; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.path.BundlePathNode; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.path.FilePathNode; import software.coley.recaf.path.PathNode; import software.coley.recaf.path.PathNodes; import software.coley.recaf.path.ResourcePathNode; import software.coley.recaf.path.WorkspacePathNode; import software.coley.recaf.services.Service; import software.coley.recaf.services.search.query.AndroidClassQuery; import software.coley.recaf.services.search.query.DeclarationQuery; import software.coley.recaf.services.search.query.FileQuery; import software.coley.recaf.services.search.query.JvmClassQuery; import software.coley.recaf.services.search.query.NumberQuery; import software.coley.recaf.services.search.query.Query; import software.coley.recaf.services.search.query.ReferenceQuery; import software.coley.recaf.services.search.query.StringQuery; import software.coley.recaf.services.search.result.ClassReference; import software.coley.recaf.services.search.result.ClassReferenceResult; import software.coley.recaf.services.search.result.MemberReference; import software.coley.recaf.services.search.result.MemberReferenceResult; import software.coley.recaf.services.search.result.NumberResult; import software.coley.recaf.services.search.result.Result; import software.coley.recaf.services.search.result.Results; import software.coley.recaf.services.search.result.StringResult; import software.coley.recaf.util.threading.ThreadPoolFactory; import software.coley.recaf.util.threading.ThreadUtil; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.AndroidClassBundle; import software.coley.recaf.workspace.model.bundle.FileBundle; import software.coley.recaf.workspace.model.resource.WorkspaceFileResource; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.Collections; import java.util.List; import java.util.concurrent.ExecutorService; /** * Outline for running various searches. * * @author Matt Coley * @see NumberQuery * @see ReferenceQuery * @see DeclarationQuery * @see StringQuery */ @ApplicationScoped public class SearchService implements Service { public static final String SERVICE_ID = "search"; private final SearchServiceConfig config; @Inject public SearchService(SearchServiceConfig config) { this.config = config; } /** * @param workspace * Workspace to search in. * @param query * Query of search parameters. * * @return Results of search. */ @Nonnull public Results search(@Nonnull Workspace workspace, @Nonnull Query query) { return search(workspace, Collections.singletonList(query)); } /** * @param workspace * Workspace to search in. * @param query * Query of search parameters. * @param feedback * Search visitation feedback. Allows early cancellation of searches. * * @return Results of search. */ @Nonnull public Results search(@Nonnull Workspace workspace, @Nonnull Query query, @Nonnull SearchFeedback feedback) { return search(workspace, Collections.singletonList(query), feedback); } /** * @param workspace * Workspace to search in. * @param queries * Multiple queries of search parameters. * * @return Results of search. */ @Nonnull public Results search(@Nonnull Workspace workspace, @Nonnull List queries) { return search(workspace, queries, SearchFeedback.DEFAULT); } /** * @param workspace * Workspace to search in. * @param queries * Multiple queries of search parameters. * @param feedback * Search visitation feedback. Allows early cancellation of searches. * * @return Results of search. */ @Nonnull public Results search(@Nonnull Workspace workspace, @Nonnull List queries, @Nonnull SearchFeedback feedback) { Results results = new Results(); // Build visitors AndroidClassSearchVisitor androidClassVisitorTemp = null; JvmClassSearchVisitor jvmClassVisitorTemp = null; FileSearchVisitor fileVisitorTemp = null; for (Query query : queries) { if (query instanceof AndroidClassQuery androidClassQuery) androidClassVisitorTemp = androidClassQuery.visitor(androidClassVisitorTemp); if (query instanceof JvmClassQuery jvmClassQuery) jvmClassVisitorTemp = jvmClassQuery.visitor(jvmClassVisitorTemp); if (query instanceof FileQuery fileQuery) fileVisitorTemp = fileQuery.visitor(fileVisitorTemp); } AndroidClassSearchVisitor androidClassVisitor = androidClassVisitorTemp; JvmClassSearchVisitor jvmClassVisitor = jvmClassVisitorTemp; FileSearchVisitor fileVisitor = fileVisitorTemp; // Run visitors on contents of workspace ExecutorService service = ThreadPoolFactory.newFixedThreadPool(SERVICE_ID + ":" + queries.hashCode()); WorkspacePathNode workspaceNode = PathNodes.workspacePath(workspace); for (WorkspaceResource resource : workspace.getAllResources(false)) searchResource(results, service, feedback, resource, workspaceNode, androidClassVisitor, jvmClassVisitor, fileVisitor); ThreadUtil.blockUntilComplete(service); // Notify feedback of search completion feedback.onCompletion(); return results; } /** * @param results * Result container to dump into. * @param service * Thread scheduler service. * @param feedback * Search feedback mechanism (To allow user cancellation and such) * @param resource * Resource to search within. * @param workspacePath * Root workspace path node. * @param androidClassVisitor * Android class search visitor. * Can be {@code null} to skip searching respective content. * @param jvmClassVisitor * JVM class search visitor. * Can be {@code null} to skip searching respective content. * @param fileVisitor * File search visitor. * Can be {@code null} to skip searching respective content. */ private static void searchResource(@Nonnull Results results, @Nonnull ExecutorService service, @Nonnull SearchFeedback feedback, @Nonnull WorkspaceResource resource, @Nonnull WorkspacePathNode workspacePath, @Nullable AndroidClassSearchVisitor androidClassVisitor, @Nullable JvmClassSearchVisitor jvmClassVisitor, @Nullable FileSearchVisitor fileVisitor) { // Recursively search embedded resources. for (WorkspaceFileResource embeddedResource : resource.getEmbeddedResources().values()) { searchResource(results, service, feedback, embeddedResource, workspacePath, androidClassVisitor, jvmClassVisitor, fileVisitor); } // Visit android content ResourcePathNode resourcePath = workspacePath.child(resource); if (androidClassVisitor != null) { for (AndroidClassBundle bundle : resource.getAndroidClassBundles().values()) { BundlePathNode bundlePath = resourcePath.child(bundle); for (AndroidClassInfo classInfo : bundle) { if (feedback.hasRequestedCancellation()) break; if (!feedback.doVisitClass(classInfo)) continue; ClassPathNode classPath = bundlePath .child(classInfo.getPackageName()) .child(classInfo); service.submit(() -> { if (feedback.hasRequestedCancellation()) return; androidClassVisitor.visit(getResultSink(results, feedback), classPath, classInfo); }); } } } // Visit JVM content if (jvmClassVisitor != null) { resource.jvmAllClassBundleStream().forEach(bundle -> { BundlePathNode bundlePath = resourcePath.child(bundle); for (JvmClassInfo classInfo : bundle) { if (feedback.hasRequestedCancellation()) break; if (!feedback.doVisitClass(classInfo)) continue; ClassPathNode classPath = bundlePath .child(classInfo.getPackageName()) .child(classInfo); service.submit(() -> { if (feedback.hasRequestedCancellation()) return; jvmClassVisitor.visit(getResultSink(results, feedback), classPath, classInfo); }); } }); } // Visit file content if (fileVisitor != null) { FileBundle fileBundle = resource.getFileBundle(); BundlePathNode bundlePath = resourcePath.child(fileBundle); for (FileInfo fileInfo : fileBundle) { if (feedback.hasRequestedCancellation()) break; if (!feedback.doVisitFile(fileInfo)) continue; FilePathNode filePath = bundlePath .child(fileInfo.getDirectoryName()) .child(fileInfo); service.submit(() -> { if (feedback.hasRequestedCancellation()) return; fileVisitor.visit(getResultSink(results, feedback), filePath, fileInfo); }); } } } @Nonnull private static ResultSink getResultSink(@Nonnull Results results, @Nullable SearchFeedback feedback) { return (path, value) -> { Result result = createResult(path, value); if (feedback == null || feedback.doAcceptResult(result)) results.add(result); }; } @Nonnull private static Result createResult(@Nonnull PathNode path, @Nonnull Object value) { if (value instanceof Number number) return new NumberResult(path, number); if (value instanceof String string) return new StringResult(path, string); if (value instanceof ClassReference reference) return new ClassReferenceResult(path, reference); if (value instanceof MemberReference reference) return new MemberReferenceResult(path, reference); // Unknown value type throw new UnsupportedOperationException("Unsupported search result value type: " + value.getClass().getName()); } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public SearchServiceConfig getServiceConfig() { return config; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/SearchServiceConfig.java ================================================ package software.coley.recaf.services.search; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link SearchService}. * * @author Matt Coley */ @ApplicationScoped public class SearchServiceConfig extends BasicConfigContainer implements ServiceConfig { @Inject public SearchServiceConfig() { super(ConfigGroups.SERVICE_ANALYSIS, SearchService.SERVICE_ID + CONFIG_SUFFIX); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/SearchVisitor.java ================================================ package software.coley.recaf.services.search; /** * Common search visitor type. * * @author Matt Coley * @see AndroidClassSearchVisitor * @see JvmClassSearchVisitor * @see FileSearchVisitor */ public interface SearchVisitor { } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/match/BiNumberMatcher.java ================================================ package software.coley.recaf.services.search.match; import jakarta.annotation.Nonnull; /** * Matcher outline for comparing one number to another. * * @author Matt Coley */ public interface BiNumberMatcher { /** * @param key * Target value to match against. * @param target * Value to check. * * @return {@code true} when the target value matches the key value. */ boolean test(@Nonnull Number key, @Nonnull Number target); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/match/BiStringMatcher.java ================================================ package software.coley.recaf.services.search.match; import jakarta.annotation.Nonnull; /** * Matcher outline for comparing one string to another. * * @author Matt Coley */ public interface BiStringMatcher { /** * @param key * Target value to match against. * @param target * Value to check. * * @return {@code true} when the target value matches the key value. */ boolean test(@Nonnull String key, @Nonnull String target); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/match/MultiNumberMatcher.java ================================================ package software.coley.recaf.services.search.match; import jakarta.annotation.Nonnull; import java.util.Collection; /** * Matcher outline for comparing one number to multiple numbers. * * @author Matt Coley */ public interface MultiNumberMatcher { /** * @param keys * Target values to match against. * @param target * Value to check. * * @return {@code true} when the target value matches the key value(s). */ boolean test(@Nonnull Collection keys, @Nonnull Number target); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/match/MultiStringMatcher.java ================================================ package software.coley.recaf.services.search.match; import jakarta.annotation.Nonnull; import java.util.Collection; /** * Matcher outline for comparing one string to multiple strings. * * @author Matt Coley */ public interface MultiStringMatcher { /** * @param keys * Target values to match against. * @param target * Value to check. * * @return {@code true} when the target value matches the key value(s). */ boolean test(@Nonnull Collection keys, @Nonnull String target); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/match/NumberPredicate.java ================================================ package software.coley.recaf.services.search.match; import jakarta.annotation.Nonnull; import java.util.function.Predicate; /** * Matcher implementations for numeric values. * * @author Matt Coley */ public class NumberPredicate { /** Translation key prefix */ public static final String TRANSLATION_PREFIX = "number.match."; private final Predicate delegate; private final String id; /** * @param id * Predicate ID. * @param delegate * Matcher predicate implementation. */ public NumberPredicate(@Nonnull String id, @Nonnull Predicate delegate) { this.delegate = delegate; this.id = id; } /** * @return Predicate ID. */ @Nonnull public String getId() { return id; } /** * @return Translation key for predicate. */ @Nonnull public String getTranslationKey() { return TRANSLATION_PREFIX + getId(); } /** * @param value * Value to test for a match. * * @return {@code true} if the given value matches. */ public boolean match(@Nonnull Number value) { return delegate.test(value); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/match/NumberPredicateProvider.java ================================================ package software.coley.recaf.services.search.match; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import static software.coley.recaf.util.NumberUtil.cmp; /** * Provider of {@link NumberPredicate} instances. * * @author Matt Coley */ @ApplicationScoped public class NumberPredicateProvider { /** * Key in {@link #newBiNumberPredicate(String, Number)} for equality matching. */ public static String KEY_EQUAL = "equal"; /** * Key in {@link #newBiNumberPredicate(String, Number)} for inequality matching. */ public static String KEY_NOT = "not"; /** * Key in {@link #newBiNumberPredicate(String, Number)} for greater-than matching. */ public static String KEY_GREATER_THAN = "gt"; /** * Key in {@link #newBiNumberPredicate(String, Number)} for greater-than or equal matching. */ public static String KEY_GREATER_EQUAL_THAN = "gte"; /** * Key in {@link #newBiNumberPredicate(String, Number)} for less-than matching. */ public static String KEY_LESS_THAN = "lt"; /** * Key in {@link #newBiNumberPredicate(String, Number)} for less-than or equal matching. */ public static String KEY_LESS_EQUAL_THAN = "lte"; /** * Key in {@link #newRangePredicate(Number, Number, boolean, boolean)} for {@code (min, max)} matching. */ public static String KEY_RANGE_GT_LT = "gt-lt"; /** * Key in {@link #newRangePredicate(Number, Number, boolean, boolean)} for {@code [min, max)} matching. */ public static String KEY_RANGE_GTE_LT = "gte-lt"; /** * Key in {@link #newRangePredicate(Number, Number, boolean, boolean)} for {@code (min, max]} matching. */ public static String KEY_RANGE_GT_LTE = "gt-lte"; /** * Key in {@link #newRangePredicate(Number, Number, boolean, boolean)} for {@code [min, max]} matching. */ public static String KEY_RANGE_GTE_LTE = "gte-lte"; /** * Key in {@link #newMultiNumberPredicate(String, Collection)} for any-single item matching. */ public static String KEY_ANY_OF = "any-of"; private final Map biNumberMatchers = new ConcurrentHashMap<>(); private final Map rangeNumberMatchers = new ConcurrentHashMap<>(); private final Map multiNumberMatchers = new ConcurrentHashMap<>(); @Inject public NumberPredicateProvider() { registerBiMatcher(KEY_EQUAL, (key, value) -> cmp(key, value) == 0); registerBiMatcher(KEY_NOT, (key, value) -> cmp(key, value) != 0); registerBiMatcher(KEY_GREATER_THAN, (key, value) -> cmp(key, value) < 0); registerBiMatcher(KEY_GREATER_EQUAL_THAN, (key, value) -> cmp(key, value) <= 0); registerBiMatcher(KEY_LESS_THAN, (key, value) -> cmp(key, value) > 0); registerBiMatcher(KEY_LESS_EQUAL_THAN, (key, value) -> cmp(key, value) >= 0); registerRangeMatcher(KEY_RANGE_GT_LT, (lower, upper, value) -> cmp(lower, value) < 0 && cmp(upper, value) > 0); registerRangeMatcher(KEY_RANGE_GTE_LT, (lower, upper, value) -> cmp(lower, value) <= 0 && cmp(upper, value) > 0); registerRangeMatcher(KEY_RANGE_GT_LTE, (lower, upper, value) -> cmp(lower, value) < 0 && cmp(upper, value) >= 0); registerRangeMatcher(KEY_RANGE_GTE_LTE, (lower, upper, value) -> cmp(lower, value) <= 0 && cmp(upper, value) >= 0); registerMultiMatcher(KEY_ANY_OF, (keys, value) -> { for (Number key : keys) if (cmp(key, value) == 0) return true; return false; }); } /** * @param id * Unique ID to register with. * @param matcher * Matcher implementation. * * @return {@code true} on success. {@code false} if the ID is already in-use. */ public boolean registerBiMatcher(@Nonnull String id, @Nonnull BiNumberMatcher matcher) { return biNumberMatchers.putIfAbsent(id, matcher) == null; } /** * @param id * Unique ID to register with. * @param matcher * Matcher implementation. * * @return {@code true} on success. {@code false} if the ID is already in-use. */ public boolean registerMultiMatcher(@Nonnull String id, @Nonnull MultiNumberMatcher matcher) { return multiNumberMatchers.putIfAbsent(id, matcher) == null; } /** * @param id * Unique ID to register with. * @param matcher * Matcher implementation. * * @return {@code true} on success. {@code false} if the ID is already in-use. */ public boolean registerRangeMatcher(@Nonnull String id, @Nonnull RangeNumberMatcher matcher) { return rangeNumberMatchers.putIfAbsent(id, matcher) == null; } /** * @param numbers * Array of numbers to match. * * @return Predicate to target the given numbers. */ @Nonnull public NumberPredicate newAnyOfPredicate(@Nonnull Number... numbers) { List numbersList = List.of(numbers); return newAnyOfPredicate(numbersList); } /** * @param numbers * Collection of numbers to match. * * @return Predicate to target the given numbers. */ @Nonnull public NumberPredicate newAnyOfPredicate(@Nonnull Collection numbers) { return Objects.requireNonNull(newMultiNumberPredicate("any-of", numbers)); } /** * @param lower * Lower inclusive bound to match. * @param upper * Upper inclusive bound to match. * * @return Predicate to target the numbers in the given range. */ @Nonnull public NumberPredicate newRangePredicate(@Nonnull Number lower, @Nonnull Number upper) { return newRangePredicate(lower, upper, true, true); } /** * @param lower * Lower bound to match. * @param upper * Upper bound to match. * @param inclusiveLower * {@code true} to make the lower bound inclusive. * @param inclusiveUpper * {@code true} to make the upper bound inclusive. * * @return Predicate to target the numbers in the given range. */ @Nonnull public NumberPredicate newRangePredicate(@Nonnull Number lower, @Nonnull Number upper, boolean inclusiveLower, boolean inclusiveUpper) { String id = (inclusiveLower ? "gte" : "gt") + "-" + (inclusiveUpper ? "lte" : "lt"); return Objects.requireNonNull(newRangeNumberPredicate(id, lower, upper)); } /** * @param key * Number to match against. * * @return Predicate to target the given number. */ @Nonnull public NumberPredicate newEqualsPredicate(@Nonnull Number key) { return Objects.requireNonNull(newBiNumberPredicate("equal", key)); } /** * @param key * Number to match against. * * @return Predicate to target anything but the given number. */ @Nonnull public NumberPredicate newNotEqualsPredicate(@Nonnull Number key) { return Objects.requireNonNull(newBiNumberPredicate("not", key)); } /** * @param key * Number to match against. * * @return Predicate to target any number greater than the given number. */ @Nonnull public NumberPredicate newGreaterThanPredicate(@Nonnull Number key) { return Objects.requireNonNull(newBiNumberPredicate("gt", key)); } /** * @param key * Number to match against. * * @return Predicate to target any number greater than or equal to the given number. */ @Nonnull public NumberPredicate newGreaterThanOrEqualPredicate(@Nonnull Number key) { return Objects.requireNonNull(newBiNumberPredicate("gte", key)); } /** * @param key * Number to match against. * * @return Predicate to target any number less than the given number. */ @Nonnull public NumberPredicate newLessThanPredicate(@Nonnull Number key) { return Objects.requireNonNull(newBiNumberPredicate("lt", key)); } /** * @param key * Number to match against. * * @return Predicate to target any number less than or equal to the given number. */ @Nonnull public NumberPredicate newLessThanOrEqualPredicate(@Nonnull Number key) { return Objects.requireNonNull(newBiNumberPredicate("lte", key)); } /** * @param id * Matcher unique ID. * @param key * Number to match against. * * @return Predicate to target the given number. * * @throws NoSuchElementException * When no matcher implementation is registered with the given ID. */ @Nullable public NumberPredicate newBiNumberPredicate(@Nonnull String id, @Nonnull Number key) throws NoSuchElementException { BiNumberMatcher matcher = biNumberMatchers.get(id); if (matcher != null) return new NumberPredicate(id, target -> matcher.test(key, target)); throw new NoSuchElementException("No such single-parameter matcher: " + id); } /** * @param id * Matcher unique ID. * @param lower * Lower bound number to match against. * @param upper * Upper bound number to match against. * * @return Predicate to target the numbers in the given range. * * @throws NoSuchElementException * When no matcher implementation is registered with the given ID. */ @Nullable public NumberPredicate newRangeNumberPredicate(@Nonnull String id, @Nonnull Number lower, @Nonnull Number upper) throws NoSuchElementException { RangeNumberMatcher matcher = rangeNumberMatchers.get(id); if (matcher != null) return new NumberPredicate(id, target -> matcher.test(lower, upper, target)); throw new NoSuchElementException("No such ranged-parameter matcher: " + id); } /** * @param id * Matcher unique ID. * @param keys * Collection of numbers to match against. * * @return Predicate to target the given numbers. * * @throws NoSuchElementException * When no matcher implementation is registered with the given ID. */ @Nullable public NumberPredicate newMultiNumberPredicate(@Nonnull String id, @Nonnull Collection keys) throws NoSuchElementException { MultiNumberMatcher matcher = multiNumberMatchers.get(id); if (matcher != null) return new NumberPredicate(id, target -> matcher.test(keys, target)); throw new NoSuchElementException("No such multi-parameter matcher: " + id); } /** * @return Map of matcher keys to implementations. */ @Nonnull public Map getBiNumberMatchers() { return Collections.unmodifiableMap(biNumberMatchers); } /** * @return Map of matcher keys to implementations. */ @Nonnull public Map getRangeNumberMatchers() { return Collections.unmodifiableMap(rangeNumberMatchers); } /** * @return Map of matcher keys to implementations. */ @Nonnull public Map getMultiNumberMatchers() { return Collections.unmodifiableMap(multiNumberMatchers); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/match/RangeNumberMatcher.java ================================================ package software.coley.recaf.services.search.match; import jakarta.annotation.Nonnull; /** * Matcher outline for comparing one number to a range of numbers. * * @author Matt Coley */ public interface RangeNumberMatcher { /** * @param lower * Lower target value range to match against. * @param upper * Upper target value range to match against. * @param target * Value to check. * * @return {@code true} when the target value matches the given range. */ boolean test(@Nonnull Number lower, @Nonnull Number upper, @Nonnull Number target); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/match/StringPredicate.java ================================================ package software.coley.recaf.services.search.match; import jakarta.annotation.Nonnull; import java.util.function.Predicate; /** * Matcher implementations for string values. * * @author Matt Coley */ public class StringPredicate { /** Translation key prefix */ public static String TRANSLATION_PREFIX = "string.match."; private final Predicate delegate; private final String id; /** * @param id * Predicate ID. * @param delegate * Matcher predicate implementation. */ public StringPredicate(@Nonnull String id, @Nonnull Predicate delegate) { this.delegate = delegate; this.id = id; } /** * @return Predicate ID. */ @Nonnull public String getId() { return id; } /** * @return Translation key for predicate. */ @Nonnull public String getTranslationKey() { return TRANSLATION_PREFIX + getId(); } /** * @param text * Text to test for a match. * * @return {@code true} if the given string matches with a given key value. */ public boolean match(@Nonnull String text) { return delegate.test(text); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/match/StringPredicateProvider.java ================================================ package software.coley.recaf.services.search.match; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.util.RegexUtil; import java.util.*; import java.util.concurrent.ConcurrentHashMap; /** * Provider of {@link StringPredicate} instances. * * @author Matt Coley */ @ApplicationScoped public class StringPredicateProvider { /** * Key in {@link #newBiStringPredicate(String, String)} for equality matching. */ public static final String KEY_ANYTHING = "anything"; /** * Key in {@link #newBiStringPredicate(String, String)} for equality matching. */ public static final String KEY_NOTHING = "zilch"; // Use 'zilch' instead of 'nothing' so that the natural key ordering puts it last /** * Key in {@link #newBiStringPredicate(String, String)} for equality matching. */ public static final String KEY_EQUALS = "equal"; /** * Key in {@link #newBiStringPredicate(String, String)} for case-insensitive equality matching. */ public static final String KEY_EQUALS_IGNORE_CASE = "equal-ic"; /** * Key in {@link #newBiStringPredicate(String, String)} for containment matching. */ public static final String KEY_CONTAINS = "contains"; /** * Key in {@link #newBiStringPredicate(String, String)} for case-insensitive containment matching. */ public static final String KEY_CONTAINS_IGNORE_CASE = "contains-ic"; /** * Key in {@link #newBiStringPredicate(String, String)} for prefix matching. */ public static final String KEY_STARTS_WITH = "starts"; /** * Key in {@link #newBiStringPredicate(String, String)} for case-insensitive prefix matching. */ public static final String KEY_STARTS_WITH_IGNORE_CASE = "starts-ic"; /** * Key in {@link #newBiStringPredicate(String, String)} for suffix matching. */ public static final String KEY_ENDS_WITH = "ends"; /** * Key in {@link #newBiStringPredicate(String, String)} for case-insensitive suffix matching. */ public static final String KEY_ENDS_WITH_IGNORE_CASE = "ends-ic"; /** * Key in {@link #newBiStringPredicate(String, String)} for partial regex matching. */ public static final String KEY_REGEX_PARTIAL = "regex-partial"; /** * Key in {@link #newBiStringPredicate(String, String)} for full regex matching. */ public static final String KEY_REFEX_FULL = "regex-full"; private static final BiStringMatcher MATHER_ANYTHING = (a, b) -> true; private static final BiStringMatcher MATHER_NOTHING = (a, b) -> false; private static final StringPredicate PREDICATE_ANYTHING = new StringPredicate(KEY_ANYTHING, a -> true); private static final StringPredicate PREDICATE_NOTHING = new StringPredicate(KEY_NOTHING, a -> false); private final Map biStringMatchers = new ConcurrentHashMap<>(); private final Map multiStringMatchers = new ConcurrentHashMap<>(); @Inject public StringPredicateProvider() { registerBiMatcher(KEY_ANYTHING, MATHER_ANYTHING); registerBiMatcher(KEY_NOTHING, MATHER_NOTHING); registerBiMatcher(KEY_EQUALS, String::equals); registerBiMatcher(KEY_EQUALS_IGNORE_CASE, String::equalsIgnoreCase); registerBiMatcher(KEY_CONTAINS, (key, value) -> value.contains(key)); registerBiMatcher(KEY_CONTAINS_IGNORE_CASE, (key, value) -> value.toLowerCase().contains(key.toLowerCase())); registerBiMatcher(KEY_STARTS_WITH, (key, value) -> value.startsWith(key)); registerBiMatcher(KEY_STARTS_WITH_IGNORE_CASE, (key, value) -> value.toLowerCase().startsWith(key.toLowerCase())); registerBiMatcher(KEY_ENDS_WITH, (key, value) -> value.endsWith(key)); registerBiMatcher(KEY_ENDS_WITH_IGNORE_CASE, (key, value) -> value.toLowerCase().endsWith(key.toLowerCase())); registerBiMatcher(KEY_REGEX_PARTIAL, (key, value) -> { try { return RegexUtil.getMatcher(key, value).find(); } catch (Throwable t) { // Invalid regex pattern, logged by regex-util return false; } }); registerBiMatcher(KEY_REFEX_FULL, (key, value) -> { try { return RegexUtil.getMatcher(key, value).matches(); } catch (Throwable t) { // Invalid regex pattern, logged by regex-util return false; } }); } /** * @param id * Unique ID to register with. * @param matcher * Matcher implementation. * * @return {@code true} on success. {@code false} if the ID is already in-use. */ public boolean registerBiMatcher(@Nonnull String id, @Nonnull BiStringMatcher matcher) { return biStringMatchers.putIfAbsent(id, matcher) == null; } /** * @param id * Unique ID to register with. * @param matcher * Matcher implementation. * * @return {@code true} on success. {@code false} if the ID is already in-use. */ public boolean registerMultiMatcher(@Nonnull String id, @Nonnull MultiStringMatcher matcher) { return multiStringMatchers.putIfAbsent(id, matcher) == null; } /** * @return Predicate that matches anything. */ @Nonnull public StringPredicate newAnythingPredicate() { return PREDICATE_ANYTHING; } /** * @return Predicate that matches nothing. */ @Nonnull public StringPredicate newNothingPredicate() { return PREDICATE_NOTHING; } /** * @param key * String to match against, case-sensitive. * * @return Predicate to target the given string. */ @Nonnull public StringPredicate newEqualPredicate(@Nonnull String key) { return newEqualPredicate(key, true); } /** * @param key * String to match against. * @param caseSensitive * Whether the match should be case-sensitive or not. * * @return Predicate to target the given string. */ @Nonnull public StringPredicate newEqualPredicate(@Nonnull String key, boolean caseSensitive) { return Objects.requireNonNull(newBiStringPredicate(caseSensitive ? "equal" : "equal-ic", key)); } /** * @param key * String to match against, case-sensitive. * * @return Predicate to target the given string. */ @Nonnull public StringPredicate newContainsPredicate(@Nonnull String key) { return newContainsPredicate(key, true); } /** * @param key * String to match against. * @param caseSensitive * Whether the match should be case-sensitive or not. * * @return Predicate to target the given string. */ @Nonnull public StringPredicate newContainsPredicate(@Nonnull String key, boolean caseSensitive) { return Objects.requireNonNull(newBiStringPredicate(caseSensitive ? "contains" : "contains-ic", key)); } /** * @param key * String to match against, case-sensitive. * * @return Predicate to target the given string. */ @Nonnull public StringPredicate newStartsWithPredicate(@Nonnull String key) { return newStartsWithPredicate(key, true); } /** * @param key * String to match against. * @param caseSensitive * Whether the match should be case-sensitive or not. * * @return Predicate to target the given string. */ @Nonnull public StringPredicate newStartsWithPredicate(@Nonnull String key, boolean caseSensitive) { return Objects.requireNonNull(newBiStringPredicate(caseSensitive ? "starts" : "starts-ic", key)); } /** * @param key * String to match against, case-sensitive. * * @return Predicate to target the given string. */ @Nonnull public StringPredicate newEndsWithPredicate(@Nonnull String key) { return newEndsWithPredicate(key, true); } /** * @param key * String to match against. * @param caseSensitive * Whether the match should be case-sensitive or not. * * @return Predicate to target the given string. */ @Nonnull public StringPredicate newEndsWithPredicate(@Nonnull String key, boolean caseSensitive) { return Objects.requireNonNull(newBiStringPredicate(caseSensitive ? "ends" : "ends-ic", key)); } /** * @param regex * Pattern to match against. Only part of the target string needs to match. * * @return Predicate to target the given string. */ @Nonnull public StringPredicate newPartialRegexPredicate(@Nonnull String regex) { return Objects.requireNonNull(newBiStringPredicate("regex-partial", regex)); } /** * @param regex * Pattern to match against. The entire target string needs to match. * * @return Predicate to target the given string. */ @Nonnull public StringPredicate newFullRegexPredicate(@Nonnull String regex) { return Objects.requireNonNull(newBiStringPredicate("regex-full", regex)); } /** * @param id * Matcher unique ID. * @param key * String to match against. * * @return Predicate to target the given string. * * @throws NoSuchElementException * When no matcher implementation is registered with the given ID. */ @Nullable public StringPredicate newBiStringPredicate(@Nonnull String id, @Nonnull String key) throws NoSuchElementException { BiStringMatcher matcher = biStringMatchers.get(id); if (matcher != null) return new StringPredicate(id, target -> matcher.test(key, target)); throw new NoSuchElementException("No such single-parameter matcher: " + id); } /** * @param id * Matcher unique ID. * @param keys * Collection of strings to match against. * * @return Predicate to target the given strings. * * @throws NoSuchElementException * When no matcher implementation is registered with the given ID. */ @Nullable public StringPredicate newMultiStringPredicate(@Nonnull String id, @Nonnull Collection keys) throws NoSuchElementException { MultiStringMatcher matcher = multiStringMatchers.get(id); if (matcher != null) return new StringPredicate(id, target -> matcher.test(keys, target)); throw new NoSuchElementException("No such multi-parameter matcher: " + id); } /** * @return Map of matcher keys to implementations. */ @Nonnull public Map getBiStringMatchers() { return Collections.unmodifiableMap(biStringMatchers); } /** * @return Map of matcher keys to implementations. */ @Nonnull public Map getMultiStringMatchers() { return Collections.unmodifiableMap(multiStringMatchers); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/query/AbstractValueQuery.java ================================================ package software.coley.recaf.services.search.query; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.objectweb.asm.*; import org.objectweb.asm.tree.InsnNode; import org.objectweb.asm.tree.IntInsnNode; import org.objectweb.asm.tree.InvokeDynamicInsnNode; import org.objectweb.asm.tree.LdcInsnNode; import org.slf4j.Logger; import software.coley.recaf.RecafConstants; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.annotation.Annotated; import software.coley.recaf.info.annotation.AnnotationInfo; import software.coley.recaf.info.annotation.BasicAnnotationInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.path.AnnotationPathNode; import software.coley.recaf.path.ClassMemberPathNode; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.path.PathNode; import software.coley.recaf.services.search.JvmClassSearchVisitor; import software.coley.recaf.services.search.ResultSink; import software.coley.recaf.util.visitors.IndexCountingMethodVisitor; /** * General value search. * * @author Matt Coley * @see StringQuery * @see NumberQuery */ public abstract class AbstractValueQuery implements JvmClassQuery, FileQuery { private static final Number[] OP_TO_VALUE = { 0, // NOP 0, // NULL -1, 0, 1, 2, 3, 4, 5, // ICONST_X 0L, 1L, // LCONST_X 0F, 1F, 2F, // FCONST_X 0D, 1D // DCONST_X }; // TODO: Implement android query when android capabilities are fleshed out enough to have comparable // search capabilities in method code protected abstract boolean isMatch(Object value); @Nonnull @Override public JvmClassSearchVisitor visitor(@Nullable JvmClassSearchVisitor delegate) { return new JvmVisitor(delegate); } /** * Points {@link #visitor(JvmClassSearchVisitor)} to {@link AsmClassValueVisitor} */ private class JvmVisitor implements JvmClassSearchVisitor { private final JvmClassSearchVisitor delegate; private JvmVisitor(@Nullable JvmClassSearchVisitor delegate) { this.delegate = delegate; } @Override public void visit(@Nonnull ResultSink resultSink, @Nonnull ClassPathNode classPath, @Nonnull JvmClassInfo classInfo) { if (delegate != null) delegate.visit(resultSink, classPath, classInfo); classInfo.getClassReader().accept(new AsmClassValueVisitor(resultSink, classPath, classInfo), 0); } } /** * Visits values in classes. */ private class AsmClassValueVisitor extends ClassVisitor { private final Logger logger = Logging.get(AsmClassValueVisitor.class); private final ResultSink resultSink; private final ClassPathNode classPath; private final JvmClassInfo classInfo; protected AsmClassValueVisitor(@Nonnull ResultSink resultSink, @Nonnull ClassPathNode classPath, @Nonnull JvmClassInfo classInfo) { super(RecafConstants.getAsmVersion()); this.resultSink = resultSink; this.classPath = classPath; this.classInfo = classInfo; } @Override public FieldVisitor visitField(int access, String name, String desc, String signature, Object value) { FieldVisitor fv = super.visitField(access, name, desc, signature, value); FieldMember fieldMember = classInfo.getDeclaredField(name, desc); if (fieldMember != null) { if (isMatch(value)) resultSink.accept(classPath.child(fieldMember), value); return new AsmFieldValueVisitor(fv, fieldMember, resultSink, classPath); } else { logger.error("Failed to lookup field for query: {}.{} {}", classInfo.getName(), name, desc); return fv; } } @Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions); MethodMember methodMember = classInfo.getDeclaredMethod(name, desc); if (methodMember != null) { return new AsmMethodValueVisitor(mv, methodMember, resultSink, classPath); } else { logger.error("Failed to lookup method for query: {}.{}{}", classInfo.getName(), name, desc); return mv; } } @Override public AnnotationVisitor visitAnnotation(String desc, boolean visible) { AnnotationVisitor av = super.visitAnnotation(desc, visible); AnnotationInfo annotationInfo = classInfo.getAnnotations().stream() .filter(ai -> ai.getDescriptor().equals(desc)) .findFirst() .orElseGet(() -> new BasicAnnotationInfo(visible, desc)); return new AnnotationValueVisitor(av, visible, resultSink, classPath.child(annotationInfo)); } @Override public AnnotationVisitor visitTypeAnnotation(int typeRef, TypePath typePath, String desc, boolean visible) { AnnotationVisitor av = super.visitTypeAnnotation(typeRef, typePath, desc, visible); AnnotationInfo annotationInfo = classInfo.getAnnotations().stream() .filter(ai -> ai.getDescriptor().equals(desc)) .findFirst() .orElseGet(() -> new BasicAnnotationInfo(visible, desc)); return new AnnotationValueVisitor(av, visible, resultSink, classPath.child(annotationInfo .withTypeInfo(typeRef, typePath))); } } /** * Visits values in fields. */ private class AsmFieldValueVisitor extends FieldVisitor { private final ResultSink resultSink; private final ClassMemberPathNode memberPath; public AsmFieldValueVisitor(@Nullable FieldVisitor delegate, @Nonnull FieldMember fieldMember, @Nonnull ResultSink resultSink, @Nonnull ClassPathNode classLocation) { super(RecafConstants.getAsmVersion(), delegate); this.resultSink = resultSink; this.memberPath = classLocation.child(fieldMember); } @Override public AnnotationVisitor visitAnnotation(String desc, boolean visible) { AnnotationVisitor av = super.visitAnnotation(desc, visible); return new AnnotationValueVisitor(av, visible, resultSink, memberPath.childAnnotation(new BasicAnnotationInfo(visible, desc))); } @Override public AnnotationVisitor visitTypeAnnotation(int typeRef, TypePath typePath, String desc, boolean visible) { AnnotationVisitor av = super.visitTypeAnnotation(typeRef, typePath, desc, visible); return new AnnotationValueVisitor(av, visible, resultSink, memberPath.childAnnotation(new BasicAnnotationInfo(visible, desc) .withTypeInfo(typeRef, typePath))); } } /** * Visits values in methods. */ private class AsmMethodValueVisitor extends IndexCountingMethodVisitor { private final ResultSink resultSink; private final ClassMemberPathNode memberPath; public AsmMethodValueVisitor(@Nullable MethodVisitor delegate, @Nonnull MethodMember methodMember, @Nonnull ResultSink resultSink, @Nonnull ClassPathNode classLocation) { super(delegate); this.resultSink = resultSink; this.memberPath = classLocation.child(methodMember); } @Override public void visitInvokeDynamicInsn(String name, String desc, Handle bsmHandle, Object... bsmArgs) { super.visitInvokeDynamicInsn(name, desc, bsmHandle, bsmArgs); for (Object bsmArg : bsmArgs) { if (isMatch(bsmArg)) { InvokeDynamicInsnNode indy = new InvokeDynamicInsnNode(name, desc, bsmHandle, bsmArgs); resultSink.accept(memberPath.childInsn(indy, index), bsmArg); } } } @Override public void visitInsn(int opcode) { super.visitInsn(opcode); if (opcode >= Opcodes.ICONST_M1 && opcode <= Opcodes.DCONST_1) { Number value = OP_TO_VALUE[opcode]; if (isMatch(value)) resultSink.accept(memberPath.childInsn(new InsnNode(opcode), index), value); } } @Override public void visitIntInsn(int opcode, int operand) { super.visitIntInsn(opcode, operand); if (opcode != Opcodes.NEWARRAY && isMatch(operand)) resultSink.accept(memberPath.childInsn(new IntInsnNode(opcode, operand), index), operand); } @Override public void visitLdcInsn(Object value) { super.visitLdcInsn(value); if (isMatch(value)) resultSink.accept(memberPath.childInsn(new LdcInsnNode(value), index), value); } @Override public void visitLookupSwitchInsn(Label dflt, int[] keys, Label[] labels) { super.visitLookupSwitchInsn(dflt, keys, labels); for (int key : keys) { if (isMatch(key)) { resultSink.accept(memberPath.childInsn(new InsnNode(Opcodes.LOOKUPSWITCH), index), key); } } } @Override public void visitTableSwitchInsn(int min, int max, Label dflt, Label... labels) { super.visitTableSwitchInsn(min, max, dflt, labels); for (int i = min; i <= max; i++) { if (isMatch(i)) { resultSink.accept(memberPath.childInsn(new InsnNode(Opcodes.TABLESWITCH), index), i); } } } @Override public AnnotationVisitor visitAnnotation(String desc, boolean visible) { AnnotationVisitor av = super.visitAnnotation(desc, visible); return new AnnotationValueVisitor(av, visible, resultSink, memberPath.childAnnotation(new BasicAnnotationInfo(visible, desc))); } @Override public AnnotationVisitor visitTypeAnnotation(int typeRef, TypePath typePath, String desc, boolean visible) { AnnotationVisitor av = super.visitTypeAnnotation(typeRef, typePath, desc, visible); return new AnnotationValueVisitor(av, visible, resultSink, memberPath.childAnnotation(new BasicAnnotationInfo(visible, desc) .withTypeInfo(typeRef, typePath))); } @Override public AnnotationVisitor visitAnnotationDefault() { AnnotationVisitor av = super.visitAnnotationDefault(); return new AnnotationValueVisitor(av, true, resultSink, memberPath); } @Override public AnnotationVisitor visitParameterAnnotation(int parameter, String desc, boolean visible) { AnnotationVisitor av = super.visitParameterAnnotation(parameter, desc, visible); return new AnnotationValueVisitor(av, visible, resultSink, memberPath.childAnnotation(new BasicAnnotationInfo(visible, desc))); } @Override public AnnotationVisitor visitInsnAnnotation(int typeRef, TypePath typePath, String desc, boolean visible) { AnnotationVisitor av = super.visitInsnAnnotation(typeRef, typePath, desc, visible); return new AnnotationValueVisitor(av, visible, resultSink, memberPath.childAnnotation(new BasicAnnotationInfo(visible, desc) .withTypeInfo(typeRef, typePath))); } @Override public AnnotationVisitor visitTryCatchAnnotation(int typeRef, TypePath typePath, String desc, boolean visible) { AnnotationVisitor av = super.visitTryCatchAnnotation(typeRef, typePath, desc, visible); return new AnnotationValueVisitor(av, visible, resultSink, memberPath.childAnnotation(new BasicAnnotationInfo(visible, desc) .withTypeInfo(typeRef, typePath))); } @Override public AnnotationVisitor visitLocalVariableAnnotation(int typeRef, TypePath typePath, Label[] start, Label[] end, int[] index, String desc, boolean visible) { AnnotationVisitor av = super.visitLocalVariableAnnotation(typeRef, typePath, start, end, index, desc, visible); return new AnnotationValueVisitor(av, visible, resultSink, memberPath.childAnnotation(new BasicAnnotationInfo(visible, desc) .withTypeInfo(typeRef, typePath))); } } /** * Visits values in annotations. */ private class AnnotationValueVisitor extends AnnotationVisitor { private final ResultSink resultSink; private final PathNode currentAnnoLocation; private final boolean visible; public AnnotationValueVisitor(@Nullable AnnotationVisitor delegate, boolean visible, @Nonnull ResultSink resultSink, @Nonnull PathNode currentAnnoLocation) { super(RecafConstants.getAsmVersion(), delegate); this.visible = visible; this.resultSink = resultSink; this.currentAnnoLocation = currentAnnoLocation; } @Override public AnnotationVisitor visitAnnotation(String name, String descriptor) { AnnotationVisitor av = super.visitAnnotation(name, descriptor); if (currentAnnoLocation.getValue() instanceof Annotated annotated) { AnnotationInfo annotationInfo = annotated.getAnnotations().stream() .filter(ai -> ai.getDescriptor().equals(descriptor)) .findFirst() .orElseGet(() -> new BasicAnnotationInfo(visible, descriptor)); if (currentAnnoLocation instanceof ClassPathNode classPath) { return new AnnotationValueVisitor(av, visible, resultSink, classPath.child(annotationInfo)); } else if (currentAnnoLocation instanceof ClassMemberPathNode memberPath) { return new AnnotationValueVisitor(av, visible, resultSink, memberPath.childAnnotation(annotationInfo)); } else if (currentAnnoLocation instanceof AnnotationPathNode annotationPath) { return new AnnotationValueVisitor(av, visible, resultSink, annotationPath.child(annotationInfo)); } } throw new IllegalStateException("Unsupported non-annotatable path: " + currentAnnoLocation); } @Override public AnnotationVisitor visitArray(String name) { AnnotationVisitor av = super.visitArray(name); return new AnnotationValueVisitor(av, visible, resultSink, currentAnnoLocation); } @Override public void visit(String name, Object value) { super.visit(name, value); if (isMatch(value)) resultSink.accept(currentAnnoLocation, value); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/query/AndroidClassQuery.java ================================================ package software.coley.recaf.services.search.query; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.AndroidClassInfo; import software.coley.recaf.services.search.AndroidClassSearchVisitor; /** * Query targeting {@link AndroidClassInfo}. * * @author Matt Coley */ public interface AndroidClassQuery extends Query { @Nonnull AndroidClassSearchVisitor visitor(@Nullable AndroidClassSearchVisitor delegate); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/query/DeclarationQuery.java ================================================ package software.coley.recaf.services.search.query; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.services.search.AndroidClassSearchVisitor; import software.coley.recaf.services.search.JvmClassSearchVisitor; import software.coley.recaf.services.search.ResultSink; import software.coley.recaf.services.search.match.StringPredicate; import software.coley.recaf.services.search.result.MemberReference; import software.coley.recaf.util.StringUtil; /** * Declaration search implementation. * * @author Matt Coley */ public class DeclarationQuery implements JvmClassQuery, AndroidClassQuery { private final StringPredicate ownerPredicate; private final StringPredicate namePredicate; private final StringPredicate descriptorPredicate; /** * Member declaration query. *

* Do note that each target value is nullable/optional. * Including only the owner and {@code null} for the name/desc will yield declarations to all members in the class. * Including only the desc will yield declarations of all members with that desc in all classes. * * @param ownerPredicate * String matching predicate for comparison against declared member owners. * {@code null} to ignore matching against owner names. * @param namePredicate * String matching predicate for comparison against declared member names. * {@code null} to ignore matching against member names. * @param descriptorPredicate * String matching predicate for comparison against declared member descriptors. * {@code null} to ignore matching against member descriptors. */ public DeclarationQuery(@Nullable StringPredicate ownerPredicate, @Nullable StringPredicate namePredicate, @Nullable StringPredicate descriptorPredicate) { this.ownerPredicate = ownerPredicate; this.namePredicate = namePredicate; this.descriptorPredicate = descriptorPredicate; } @Nonnull @Override public AndroidClassSearchVisitor visitor(@Nullable AndroidClassSearchVisitor delegate) { return (resultSink, currentLocation, classInfo) -> { if (delegate != null) delegate.visit(resultSink, currentLocation, classInfo); scan(resultSink, currentLocation); }; } @Nonnull @Override public JvmClassSearchVisitor visitor(@Nullable JvmClassSearchVisitor delegate) { return (resultSink, currentLocation, classInfo) -> { if (delegate != null) delegate.visit(resultSink, currentLocation, classInfo); scan(resultSink, currentLocation); }; } private boolean isMemberRefMatch(@Nonnull String owner, @Nonnull String name, @Nonnull String desc) { // If our query predicates are null, that field can skip comparison, and we move on to the next. // If all of our non-null query arguments match the given parameters, we have a match. if (ownerPredicate == null || StringUtil.isNullOrEmpty(owner) || ownerPredicate.match(owner)) if (namePredicate == null || StringUtil.isNullOrEmpty(name) || namePredicate.match(name)) return descriptorPredicate == null || StringUtil.isNullOrEmpty(desc) || descriptorPredicate.match(desc); return false; } private void scan(@Nonnull ResultSink resultSink, @Nonnull ClassPathNode classPath) { ClassInfo classInfo = classPath.getValue(); for (FieldMember field : classInfo.getFields()) { String owner = classInfo.getName(); String name = field.getName(); String desc = field.getDescriptor(); if (isMemberRefMatch(owner, name, desc)) resultSink.accept(classPath.child(field), new MemberReference(owner, name, desc)); } for (MethodMember method : classInfo.getMethods()) { String owner = classInfo.getName(); String name = method.getName(); String desc = method.getDescriptor(); if (isMemberRefMatch(owner, name, desc)) resultSink.accept(classPath.child(method), new MemberReference(owner, name, desc)); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/query/FileQuery.java ================================================ package software.coley.recaf.services.search.query; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.FileInfo; import software.coley.recaf.services.search.FileSearchVisitor; /** * Query targeting {@link FileInfo}. * * @author Matt Coley */ public interface FileQuery extends Query { @Nonnull FileSearchVisitor visitor(@Nullable FileSearchVisitor delegate); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/query/InstructionQuery.java ================================================ package software.coley.recaf.services.search.query; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.objectweb.asm.ClassReader; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.MethodNode; import software.coley.recaf.path.ClassMemberPathNode; import software.coley.recaf.path.InstructionPathNode; import software.coley.recaf.services.search.JvmClassSearchVisitor; import software.coley.recaf.services.search.match.StringPredicate; import software.coley.recaf.util.BlwUtil; import java.util.ArrayList; import java.util.List; /** * Instruction text search implementation. * * @author Matt Coley */ public class InstructionQuery implements JvmClassQuery { private final List predicates; /** * @param predicates * List of predicates, where each entry matches a single line of disassembled instruction text. */ public InstructionQuery(@Nonnull List predicates) { this.predicates = predicates; } @Nonnull @Override public JvmClassSearchVisitor visitor(@Nullable JvmClassSearchVisitor delegate) { return (resultSink, classPath, classInfo) -> { ClassNode node = new ClassNode(); classInfo.getClassReader().accept(node, ClassReader.SKIP_FRAMES); List matched = new ArrayList<>(predicates.size()); for (MethodNode method : node.methods) { if (method.instructions == null) continue; ClassMemberPathNode memberPath = classPath.child(method.name, method.desc); if (memberPath == null) continue; matched.clear(); for (int i = 0; i < method.instructions.size() - predicates.size(); i++) { for (int j = 0; j < predicates.size(); j++) { int line = i + j; // This utility call maps instructions to BLW ones, and passes them to JASM // so the format should match what you see in the assembler, barring labels // and other debug info. String disassembled = BlwUtil.toString(method.instructions.get(line)); if (!predicates.get(j).match(disassembled)) { matched.clear(); break; } else { matched.add(disassembled); } } // Add result if we matched all predicates. if (matched.size() == predicates.size()) { InstructionPathNode path = memberPath.childInsn(method.instructions.get(i), i); resultSink.accept(path, String.join("\n", matched)); matched.clear(); } } } }; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/query/JvmClassQuery.java ================================================ package software.coley.recaf.services.search.query; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.services.search.JvmClassSearchVisitor; /** * Query targeting {@link JvmClassInfo}. * * @author Matt Coley */ public interface JvmClassQuery extends Query { @Nonnull JvmClassSearchVisitor visitor(@Nullable JvmClassSearchVisitor delegate); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/query/NumberQuery.java ================================================ package software.coley.recaf.services.search.query; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import regexodus.Matcher; import software.coley.recaf.info.FileInfo; import software.coley.recaf.path.FilePathNode; import software.coley.recaf.services.search.FileSearchVisitor; import software.coley.recaf.services.search.ResultSink; import software.coley.recaf.services.search.match.NumberPredicate; import software.coley.recaf.util.NumberUtil; import static software.coley.recaf.util.RegexUtil.getMatcher; /** * Number search implementation. * * @author Matt Coley */ public class NumberQuery extends AbstractValueQuery { private final NumberPredicate predicate; /** * @param predicate * Number matching predicate. */ public NumberQuery(@Nonnull NumberPredicate predicate) { this.predicate = predicate; } @Override protected boolean isMatch(Object value) { if (value instanceof Number number) return predicate.match(number); return false; } @Nonnull @Override public FileSearchVisitor visitor(@Nullable FileSearchVisitor delegate) { return new FileVisitor(delegate); } /** * Points {@link #visitor(FileSearchVisitor)} to file content. */ private class FileVisitor implements FileSearchVisitor { private final FileSearchVisitor delegate; private FileVisitor(FileSearchVisitor delegate) { this.delegate = delegate; } @Override public void visit(@Nonnull ResultSink resultSink, @Nonnull FilePathNode filePath, @Nonnull FileInfo fileInfo) { if (delegate != null) delegate.visit(resultSink, filePath, fileInfo); // Search text files text content on a line by line basis if (fileInfo.isTextFile()) { String text = fileInfo.asTextFile().getText(); // Split by single newline (including goofy carriage returns) String[] lines = text.split("\\r?\\n\\r?"); for (int i = 0; i < lines.length; i++) { String lineText = lines[i]; // Extract numbers (decimal, hex) from line, check if match Matcher matcher = getMatcher("(?:\\b|-)(?:\\d+(?:.\\d+[DdFf]?)?|0[xX][0-9a-fA-F]+)\\b", lineText); while (matcher.find()) { String group = matcher.group(0); try { Number value = NumberUtil.parse(group); if (isMatch(value)) resultSink.accept(filePath.child(i + 1), value); } catch (NumberFormatException ignored) { // Invalid match } } } } } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/query/Query.java ================================================ package software.coley.recaf.services.search.query; /** * Common query type. * * @author Matt Coley * @see AndroidClassQuery * @see JvmClassQuery * @see FileQuery */ public interface Query { } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/query/ReferenceQuery.java ================================================ package software.coley.recaf.services.search.query; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.objectweb.asm.AnnotationVisitor; import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.ConstantDynamic; import org.objectweb.asm.FieldVisitor; import org.objectweb.asm.Handle; import org.objectweb.asm.Label; import org.objectweb.asm.MethodVisitor; import org.objectweb.asm.Type; import org.objectweb.asm.TypePath; import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.FieldInsnNode; import org.objectweb.asm.tree.InvokeDynamicInsnNode; import org.objectweb.asm.tree.LdcInsnNode; import org.objectweb.asm.tree.MethodInsnNode; import org.objectweb.asm.tree.MultiANewArrayInsnNode; import org.objectweb.asm.tree.TypeInsnNode; import org.slf4j.Logger; import software.coley.recaf.RecafConstants; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.annotation.Annotated; import software.coley.recaf.info.annotation.AnnotationInfo; import software.coley.recaf.info.annotation.BasicAnnotationInfo; import software.coley.recaf.info.member.BasicLocalVariable; import software.coley.recaf.info.member.FieldMember; import software.coley.recaf.info.member.LocalVariable; import software.coley.recaf.info.member.MethodMember; import software.coley.recaf.path.AnnotationPathNode; import software.coley.recaf.path.ClassMemberPathNode; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.path.PathNode; import software.coley.recaf.services.search.JvmClassSearchVisitor; import software.coley.recaf.services.search.ResultSink; import software.coley.recaf.services.search.match.StringPredicate; import software.coley.recaf.services.search.result.ClassReference; import software.coley.recaf.services.search.result.MemberReference; import software.coley.recaf.util.StringUtil; import software.coley.recaf.util.Types; import software.coley.recaf.util.visitors.IndexCountingMethodVisitor; /** * Reference search implementation. * * @author Matt Coley */ public class ReferenceQuery implements JvmClassQuery { private final StringPredicate ownerPredicate; private final StringPredicate namePredicate; private final StringPredicate descriptorPredicate; private final boolean classRefOnly; /** * Class reference query. * * @param ownerPredicate * String matching predicate for comparison against reference owners. */ public ReferenceQuery(@Nonnull StringPredicate ownerPredicate) { this.ownerPredicate = ownerPredicate; this.namePredicate = null; this.descriptorPredicate = null; classRefOnly = true; } /** * Member reference query. *

* Do note that each target value is nullable/optional. * Including only the owner and {@code null} for the name/desc will yield references to all members in the class. * Including only the desc will yield references to all members of that desc in all classes. * * @param ownerPredicate * String matching predicate for comparison against reference owners. * {@code null} to ignore matching against reference owner names. * @param namePredicate * String matching predicate for comparison against reference names. * {@code null} to ignore matching against reference names. * @param descriptorPredicate * String matching predicate for comparison against reference descriptors. * {@code null} to ignore matching against reference descriptors. */ public ReferenceQuery(@Nullable StringPredicate ownerPredicate, @Nullable StringPredicate namePredicate, @Nullable StringPredicate descriptorPredicate) { this.ownerPredicate = ownerPredicate; this.namePredicate = namePredicate; this.descriptorPredicate = descriptorPredicate; classRefOnly = false; } private boolean isClassRefMatch(@Nullable String className) { if (!classRefOnly || className == null || ownerPredicate == null) return false; return StringUtil.isNullOrEmpty(className) || ownerPredicate.match(className); } private boolean isMemberRefMatch(@Nullable String owner, @Nullable String name, @Nullable String desc) { if (classRefOnly) return false; // The parameters are null if we only are searching against a type. // In these cases since we're comparing to a type, then any name/desc comparison should be ignored. if (name == null && namePredicate != null) return false; if (desc == null && descriptorPredicate != null) return false; // Check if match modes succeed. // If our query predicates are null, that field can skip comparison, and we move on to the next. // If all of our non-null query arguments match the given parameters, we have a match. if (ownerPredicate == null || StringUtil.isNullOrEmpty(owner) || ownerPredicate.match(owner)) if (namePredicate == null || StringUtil.isNullOrEmpty(name) || namePredicate.match(name)) return descriptorPredicate == null || StringUtil.isNullOrEmpty(desc) || descriptorPredicate.match(desc); return false; } @Nonnull private static String getInternalName(@Nonnull String classDesc) { return Type.getType(classDesc).getInternalName(); } @Nonnull private static ClassReference cref(@Nonnull String name) { return new ClassReference(name); } @Nonnull private static MemberReference mref(@Nonnull String owner, @Nonnull String name, @Nonnull String desc) { return new MemberReference(owner, name, desc); } @Nonnull @Override public JvmClassSearchVisitor visitor(@Nullable JvmClassSearchVisitor delegate) { return (resultSink, currentLocation, classInfo) -> { if (delegate != null) delegate.visit(resultSink, currentLocation, classInfo); classInfo.getClassReader().accept(new AsmReferenceClassVisitor(resultSink, currentLocation, classInfo), 0); }; } /** * Visits references in classes. */ private class AsmReferenceClassVisitor extends ClassVisitor { private final Logger logger = Logging.get(AsmReferenceClassVisitor.class); private final ResultSink resultSink; private final ClassPathNode classPath; private final JvmClassInfo classInfo; public AsmReferenceClassVisitor(@Nonnull ResultSink resultSink, @Nonnull ClassPathNode classPath, @Nonnull JvmClassInfo classInfo) { super(RecafConstants.getAsmVersion()); this.resultSink = resultSink; this.classPath = classPath; this.classInfo = classInfo; } @Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions); MethodMember methodMember = classInfo.getDeclaredMethod(name, desc); if (methodMember != null) { ClassMemberPathNode memberPath = classPath.child(methodMember); // Check exceptions if (exceptions != null) for (String exception : exceptions) if (isClassRefMatch(exception)) resultSink.accept(memberPath.childThrows(exception), cref(exception)); // Check descriptor components // - Only yield one match even if there are multiple class-refs in the desc Type methodType = Type.getMethodType(desc); String methodRetType = methodType.getReturnType().getInternalName(); if (isClassRefMatch(methodRetType)) resultSink.accept(memberPath, cref(methodRetType)); else for (Type argumentType : methodType.getArgumentTypes()) if (isClassRefMatch(argumentType.getInternalName())) { resultSink.accept(memberPath, cref(argumentType.getInternalName())); break; } // Visit method return new AsmReferenceMethodVisitor(mv, methodMember, resultSink, classPath); } else { logger.error("Failed to lookup method for query: {}.{}{}", classInfo.getName(), name, desc); return mv; } } @Override public FieldVisitor visitField(int access, String name, String desc, String signature, Object value) { FieldVisitor fv = super.visitField(access, name, desc, signature, value); FieldMember fieldMember = classInfo.getDeclaredField(name, desc); if (fieldMember != null) { ClassMemberPathNode memberPath = classPath.child(fieldMember); // Check descriptor String fieldType = getInternalName(desc); if (isClassRefMatch(fieldType)) resultSink.accept(memberPath, cref(fieldType)); // Visit field return new AsmReferenceFieldVisitor(fv, resultSink, memberPath); } else { logger.error("Failed to lookup field for query: {}.{}{}", classInfo.getName(), name, desc); return fv; } } } /** * Visits references in methods. */ private class AsmReferenceMethodVisitor extends IndexCountingMethodVisitor { private final ResultSink resultSink; private final ClassMemberPathNode memberPath; private final String ownerType; public AsmReferenceMethodVisitor(@Nullable MethodVisitor delegate, @Nonnull MethodMember methodMember, @Nonnull ResultSink resultSink, @Nonnull ClassPathNode classLocation) { super(delegate); this.resultSink = resultSink; this.memberPath = classLocation.child(methodMember); ownerType = classLocation.getValue().getName(); } @Override public void visitTypeInsn(int opcode, String type) { if (isClassRefMatch(type)) { TypeInsnNode insn = new TypeInsnNode(opcode, type); resultSink.accept(memberPath.childInsn(insn, index), cref(type)); } super.visitTypeInsn(opcode, type); } @Override public void visitFieldInsn(int opcode, String owner, String name, String desc) { FieldInsnNode insn = new FieldInsnNode(opcode, owner, name, desc); // Check method ref if (isMemberRefMatch(owner, name, desc)) resultSink.accept(memberPath.childInsn(insn, index), mref(owner, name, desc)); // Check types used in ref String fieldType = getInternalName(desc); if (isClassRefMatch(fieldType)) resultSink.accept(memberPath.childInsn(insn, index), cref(fieldType)); super.visitFieldInsn(opcode, owner, name, desc); } @Override public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean isInterface) { MethodInsnNode insn = new MethodInsnNode(opcode, owner, name, desc, isInterface); visitMethodLikeInsn(owner, name, desc, insn); super.visitMethodInsn(opcode, owner, name, desc, isInterface); } @Override public void visitInvokeDynamicInsn(String name, String desc, Handle bsmHandle, Object... bsmArgs) { InvokeDynamicInsnNode insn = new InvokeDynamicInsnNode(name, desc, bsmHandle, bsmArgs); visitMethodLikeInsn(ownerType, name, desc, insn); visitBsm(bsmHandle, bsmArgs, insn); super.visitInvokeDynamicInsn(name, desc, bsmHandle, bsmArgs); } @Override public void visitLdcInsn(Object value) { LdcInsnNode insn = new LdcInsnNode(value); visitArg(insn.cst, insn); super.visitLdcInsn(value); } @Override public void visitMultiANewArrayInsn(String desc, int numDimensions) { if (Types.isValidDesc(desc)) { String type = getInternalName(desc); if (isClassRefMatch(type)) { MultiANewArrayInsnNode insn = new MultiANewArrayInsnNode(desc, numDimensions); resultSink.accept(memberPath.childInsn(insn, index), cref(type)); } } super.visitMultiANewArrayInsn(desc, numDimensions); } @Override public void visitTryCatchBlock(Label start, Label end, Label handler, String type) { if (isClassRefMatch(type)) { resultSink.accept(memberPath.childCatch(type), cref(type)); } super.visitTryCatchBlock(start, end, handler, type); } @Override public void visitLocalVariable(String name, String desc, String signature, Label start, Label end, int index) { if (!Types.isValidDesc(desc) || Types.isPrimitive(desc)) { super.visitLocalVariable(name, desc, signature, start, end, index); return; } String type = getInternalName(desc); // Skip 'this' variables. Nobody cares that virtual methods have a type reference to themselves... if (index == 0 && name.equals("this") && type.equals(ownerType)) { super.visitLocalVariable(name, desc, signature, start, end, index); return; } if (isClassRefMatch(type)) { LocalVariable variable = new BasicLocalVariable(index, name, desc, signature); resultSink.accept(memberPath.childVariable(variable), cref(type)); } super.visitLocalVariable(name, desc, signature, start, end, index); } @Override public AnnotationVisitor visitAnnotationDefault() { return new AnnotationReferenceVisitor(super.visitAnnotationDefault(), true, resultSink, memberPath); } @Override public AnnotationVisitor visitAnnotation(String desc, boolean visible) { // Match annotation String type = getInternalName(desc); if (isClassRefMatch(type)) resultSink.accept(memberPath, cref(type)); AnnotationVisitor av = super.visitAnnotation(desc, visible); return new AnnotationReferenceVisitor(av, visible, resultSink, memberPath); } @Override public AnnotationVisitor visitTypeAnnotation(int typeRef, TypePath typePath, String desc, boolean visible) { // Match annotation String type = getInternalName(desc); if (isClassRefMatch(type)) resultSink.accept(memberPath, cref(type)); AnnotationVisitor av = super.visitTypeAnnotation(typeRef, typePath, desc, visible); return new AnnotationReferenceVisitor(av, visible, resultSink, memberPath); } @Override public AnnotationVisitor visitParameterAnnotation(int parameter, String desc, boolean visible) { // Match annotation String type = getInternalName(desc); if (isClassRefMatch(type)) resultSink.accept(memberPath, cref(type)); AnnotationVisitor av = super.visitParameterAnnotation(parameter, desc, visible); return new AnnotationReferenceVisitor(av, visible, resultSink, memberPath); } @Override public AnnotationVisitor visitInsnAnnotation(int typeRef, TypePath typePath, String desc, boolean visible) { // Match annotation String type = getInternalName(desc); if (isClassRefMatch(type)) resultSink.accept(memberPath, cref(type)); AnnotationVisitor av = super.visitInsnAnnotation(typeRef, typePath, desc, visible); return new AnnotationReferenceVisitor(av, visible, resultSink, memberPath); } @Override public AnnotationVisitor visitTryCatchAnnotation(int typeRef, TypePath typePath, String desc, boolean visible) { // Match annotation String type = getInternalName(desc); if (isClassRefMatch(type)) resultSink.accept(memberPath, cref(type)); AnnotationVisitor av = super.visitTryCatchAnnotation(typeRef, typePath, desc, visible); return new AnnotationReferenceVisitor(av, visible, resultSink, memberPath); } @Override public AnnotationVisitor visitLocalVariableAnnotation(int typeRef, TypePath typePath, Label[] start, Label[] end, int[] index, String desc, boolean visible) { // Match annotation String type = getInternalName(desc); if (isClassRefMatch(type)) resultSink.accept(memberPath, cref(type)); AnnotationVisitor av = super.visitLocalVariableAnnotation(typeRef, typePath, start, end, index, desc, visible); return new AnnotationReferenceVisitor(av, visible, resultSink, memberPath); } private void visitBsm(@Nonnull Handle bsmHandle, @Nonnull Object[] bsmArgs, @Nonnull AbstractInsnNode insn) { // Visit the handle visitHandle(bsmHandle, insn); // Then all the args for (Object bsmArg : bsmArgs) visitArg(bsmArg, insn); } private void visitArg(@Nonnull Object arg, @Nonnull AbstractInsnNode insn) { switch (arg) { case Type typeArg -> visitType(typeArg, insn); case Handle handleArg -> visitHandle(handleArg, insn); case ConstantDynamic dynamicArg -> { int argCount = dynamicArg.getBootstrapMethodArgumentCount(); Object[] args = new Object[argCount]; for (int i = 0; i < argCount; i++) args[i] = dynamicArg.getBootstrapMethodArgument(i); visitBsm(dynamicArg.getBootstrapMethod(), args, insn); } default -> { // no-op } } } private void visitHandle(@Nonnull Handle handle, @Nonnull AbstractInsnNode insn) { // Check handle ref String handleDesc = handle.getDesc(); if (isMemberRefMatch(handle.getOwner(), handle.getName(), handleDesc)) { resultSink.accept(memberPath.childInsn(insn, index), mref(handle.getOwner(), handle.getName(), handleDesc)); } // Check types used in ref visitType(Type.getType(handle.getDesc()), insn); } private void visitMethodLikeInsn(@Nonnull String owner, @Nonnull String name, @Nonnull String desc, @Nonnull AbstractInsnNode insn) { // Check method ref if (isMemberRefMatch(owner, name, desc)) resultSink.accept(memberPath.childInsn(insn, index), mref(owner, name, desc)); // Check types used in ref Type methodType = Type.getMethodType(desc); visitType(methodType, insn); } private void visitType(@Nonnull Type type, @Nonnull AbstractInsnNode insn) { if (type.getSort() == Type.METHOD) { String methodRetType = type.getReturnType().getInternalName(); if (isClassRefMatch(methodRetType)) resultSink.accept(memberPath.childInsn(insn, index), cref(methodRetType)); for (Type argumentType : type.getArgumentTypes()) { if (isClassRefMatch(argumentType.getInternalName())) resultSink.accept(memberPath.childInsn(insn, index), cref(argumentType.getInternalName())); } } else { String internalName = type.getInternalName(); if (isClassRefMatch(internalName)) { resultSink.accept(memberPath.childInsn(insn, index), cref(internalName)); } } } } /** * Visits references in fields. */ private class AsmReferenceFieldVisitor extends FieldVisitor { private final ResultSink resultSink; private final ClassMemberPathNode memberPath; public AsmReferenceFieldVisitor(@Nullable FieldVisitor delegate, @Nonnull ResultSink resultSink, @Nonnull ClassMemberPathNode memberPath) { super(RecafConstants.getAsmVersion(), delegate); this.resultSink = resultSink; this.memberPath = memberPath; } @Override public AnnotationVisitor visitAnnotation(String desc, boolean visible) { // Match annotation String type = getInternalName(desc); if (isClassRefMatch(type)) resultSink.accept(memberPath, cref(type)); AnnotationVisitor av = super.visitAnnotation(desc, visible); return new AnnotationReferenceVisitor(av, visible, resultSink, memberPath); } @Override public AnnotationVisitor visitTypeAnnotation(int typeRef, TypePath typePath, String desc, boolean visible) { // Match annotation String type = getInternalName(desc); if (isClassRefMatch(type)) resultSink.accept(memberPath, cref(type)); AnnotationVisitor av = super.visitTypeAnnotation(typeRef, typePath, desc, visible); return new AnnotationReferenceVisitor(av, visible, resultSink, memberPath); } } /** * Visits references in annotations. */ private class AnnotationReferenceVisitor extends AnnotationVisitor { private final ResultSink resultSink; private final PathNode currentAnnoLocation; private final boolean visible; public AnnotationReferenceVisitor(@Nullable AnnotationVisitor delegate, boolean visible, @Nonnull ResultSink resultSink, @Nonnull PathNode currentAnnoLocation) { super(RecafConstants.getAsmVersion(), delegate); this.visible = visible; this.resultSink = resultSink; this.currentAnnoLocation = currentAnnoLocation; } @Override public AnnotationVisitor visitAnnotation(String name, String descriptor) { AnnotationVisitor av = super.visitAnnotation(name, descriptor); // Match sub-annotation String type = getInternalName(descriptor); if (isClassRefMatch(type)) resultSink.accept(currentAnnoLocation, cref(type)); // Visit sub-annotation if (currentAnnoLocation.getValue() instanceof Annotated annotated) { AnnotationInfo annotationInfo = annotated.getAnnotations().stream() .filter(ai -> ai.getDescriptor().equals(descriptor)) .findFirst() .orElseGet(() -> new BasicAnnotationInfo(visible, descriptor)); if (currentAnnoLocation instanceof ClassPathNode classPath) { return new AnnotationReferenceVisitor(av, visible, resultSink, classPath.child(annotationInfo)); } else if (currentAnnoLocation instanceof ClassMemberPathNode memberPath) { return new AnnotationReferenceVisitor(av, visible, resultSink, memberPath.childAnnotation(annotationInfo)); } else if (currentAnnoLocation instanceof AnnotationPathNode annotationPath) { return new AnnotationReferenceVisitor(av, visible, resultSink, annotationPath.child(annotationInfo)); } } throw new IllegalStateException("Unsupported non-annotatable path: " + currentAnnoLocation); } @Override public AnnotationVisitor visitArray(String name) { AnnotationVisitor av = super.visitArray(name); return new AnnotationReferenceVisitor(av, visible, resultSink, currentAnnoLocation); } @Override public void visitEnum(String name, String descriptor, String value) { super.visitEnum(name, descriptor, value); // Match enum reference String owner = getInternalName(descriptor); if (isMemberRefMatch(owner, descriptor, value)) resultSink.accept(currentAnnoLocation, mref(owner, value, descriptor)); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/query/StringQuery.java ================================================ package software.coley.recaf.services.search.query; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.FileInfo; import software.coley.recaf.path.FilePathNode; import software.coley.recaf.services.search.FileSearchVisitor; import software.coley.recaf.services.search.ResultSink; import software.coley.recaf.services.search.match.StringPredicate; /** * String search implementation. * * @author Matt Coley */ public class StringQuery extends AbstractValueQuery { private final StringPredicate predicate; /** * @param predicate * String matching predicate. */ public StringQuery(@Nonnull StringPredicate predicate) { this.predicate = predicate; } @Override protected boolean isMatch(Object value) { if (value instanceof String text) return predicate.match(text); return false; } @Nonnull @Override public FileSearchVisitor visitor(@Nullable FileSearchVisitor delegate) { return new FileVisitor(delegate); } /** * Points {@link #visitor(FileSearchVisitor)} to file content. */ private class FileVisitor implements FileSearchVisitor { private final FileSearchVisitor delegate; private FileVisitor(FileSearchVisitor delegate) { this.delegate = delegate; } @Override public void visit(@Nonnull ResultSink resultSink, @Nonnull FilePathNode filePath, @Nonnull FileInfo fileInfo) { if (delegate != null) delegate.visit(resultSink, filePath, fileInfo); // Search text files text content on a line by line basis if (fileInfo.isTextFile()) { String[] lines = fileInfo.asTextFile().getTextLines(); // Split by single newline (including goofy carriage returns) for (int i = 0; i < lines.length; i++) { String lineText = lines[i]; if (isMatch(lineText)) resultSink.accept(filePath.child(i + 1), lineText); } } } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/result/ClassReference.java ================================================ package software.coley.recaf.services.search.result; import jakarta.annotation.Nonnull; /** * Class reference outline. * * @param name * Class name. * * @author Matt Coley */ public record ClassReference(@Nonnull String name) {} ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/result/ClassReferenceResult.java ================================================ package software.coley.recaf.services.search.result; import jakarta.annotation.Nonnull; import software.coley.recaf.path.PathNode; /** * Result of a class reference match. * * @author Matt Coley */ public class ClassReferenceResult extends Result { private final ClassReference ref; /** * @param path * Path to item containing the result. * @param name * Class name. */ public ClassReferenceResult(@Nonnull PathNode path, @Nonnull String name) { this(path, new ClassReference(name)); } /** * @param path * Path to item containing the result. * @param ref * Class reference. */ public ClassReferenceResult(@Nonnull PathNode path, @Nonnull ClassReference ref) { super(path); this.ref = ref; } @Nonnull @Override protected ClassReference getValue() { return ref; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/result/MemberReference.java ================================================ package software.coley.recaf.services.search.result; import jakarta.annotation.Nonnull; /** * Member reference outline. * * @param owner * Name of class declaring the member. * @param name * Member name. * @param desc * Member descriptor. * * @author Matt Coley */ public record MemberReference(@Nonnull String owner, @Nonnull String name, @Nonnull String desc) { /** * @return {@code true} when this is a reference to a field member. */ public boolean isFieldReference() { return !isMethodReference(); } /** * @return {@code true} when this is a reference to a method member. */ public boolean isMethodReference() { return desc.charAt(0) == '('; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/result/MemberReferenceResult.java ================================================ package software.coley.recaf.services.search.result; import jakarta.annotation.Nonnull; import software.coley.recaf.path.PathNode; /** * Result of a class reference match. * * @author Matt Coley */ public class MemberReferenceResult extends Result { private final MemberReference ref; /** * @param path * Path to item containing the result. * @param owner * Name of class declaring the member. * @param name * Member name. * @param desc * Member descriptor. */ public MemberReferenceResult(@Nonnull PathNode path, @Nonnull String owner, @Nonnull String name, @Nonnull String desc) { this(path, new MemberReference(owner, name, desc)); } /** * @param path * Path to item containing the result. * @param ref * Member reference. */ public MemberReferenceResult(@Nonnull PathNode path, @Nonnull MemberReference ref) { super(path); this.ref = ref; } @Nonnull @Override protected MemberReference getValue() { return ref; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/result/NumberResult.java ================================================ package software.coley.recaf.services.search.result; import jakarta.annotation.Nonnull; import software.coley.recaf.path.PathNode; /** * Result of a string match. * * @author Matt Coley */ public class NumberResult extends Result { private final Number value; /** * @param path * Path to item containing the result. * @param value * Matched value. */ public NumberResult(@Nonnull PathNode path, @Nonnull Number value) { super(path); this.value = value; } @Nonnull @Override protected Number getValue() { return value; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/result/Result.java ================================================ package software.coley.recaf.services.search.result; import jakarta.annotation.Nonnull; import software.coley.recaf.path.PathNode; import java.util.Objects; /** * The base result contains path information of the matched value. * * @author Matt Coley */ public abstract class Result implements Comparable> { private final PathNode path; /** * @param path * Path to item containing the result. */ public Result(@Nonnull PathNode path) { this.path = path; } /** * @return Wrapped value, used internally for {@link #toString()}. */ @Nonnull protected abstract T getValue(); /** * @return Path to item containing the result. */ @Nonnull public PathNode getPath() { return path; } @Override public int compareTo(@Nonnull Result o) { if (o == this) return 0; // Base comparison by path. int cmp = path.compareTo(o.path); // Disambiguate if path is the same, but values differ. if (cmp == 0) cmp = Integer.compare(getValue().hashCode(), o.getValue().hashCode()); return cmp; } @Override public String toString() { return "Result{value=" + getValue() + ", path=" + path + '}'; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Result result = (Result) o; return Objects.equals(path, result.path) && Objects.equals(getValue(), result.getValue()); } @Override public int hashCode() { int result = path.hashCode(); result = 31 * result + Objects.hashCode(getValue()); return result; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/result/Results.java ================================================ package software.coley.recaf.services.search.result; import software.coley.collections.delegate.DelegatingSortedSet; import java.util.Collections; import java.util.TreeSet; /** * Results wrapper for a search operation. * * @author Matt Coley */ public class Results extends DelegatingSortedSet> { /** * New results backed by tree-set. */ public Results() { super(Collections.synchronizedNavigableSet(new TreeSet<>())); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/search/result/StringResult.java ================================================ package software.coley.recaf.services.search.result; import jakarta.annotation.Nonnull; import software.coley.recaf.path.PathNode; /** * Result of a string match. * * @author Matt Coley */ public class StringResult extends Result { private final String value; /** * @param path * Path to item containing the result. * @param value * Matched value. */ public StringResult(@Nonnull PathNode path, @Nonnull String value) { super(path); this.value = value; } @Nonnull @Override protected String getValue() { return value; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/source/AstMapper.java ================================================ package software.coley.recaf.services.source; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.services.mapping.IntermediateMappings; import software.coley.recaf.services.mapping.Mappings; import software.coley.recaf.util.AccessFlag; import software.coley.recaf.util.StringUtil; import software.coley.sourcesolver.model.ClassModel; import software.coley.sourcesolver.model.CompilationUnitModel; import software.coley.sourcesolver.model.ImportModel; import software.coley.sourcesolver.model.MethodModel; import software.coley.sourcesolver.model.Model; import software.coley.sourcesolver.model.NameHoldingModel; import software.coley.sourcesolver.model.NamedModel; import software.coley.sourcesolver.model.VariableModel; import software.coley.sourcesolver.resolve.Resolver; import software.coley.sourcesolver.resolve.entry.ClassEntry; import software.coley.sourcesolver.resolve.entry.ClassMemberPair; import software.coley.sourcesolver.resolve.entry.FieldEntry; import software.coley.sourcesolver.resolve.entry.MemberEntry; import software.coley.sourcesolver.resolve.entry.MethodEntry; import software.coley.sourcesolver.resolve.result.ClassResolution; import software.coley.sourcesolver.resolve.result.FieldResolution; import software.coley.sourcesolver.resolve.result.MethodResolution; import software.coley.sourcesolver.resolve.result.MultiClassResolution; import software.coley.sourcesolver.resolve.result.MultiMemberResolution; import software.coley.sourcesolver.resolve.result.PrimitiveResolution; import software.coley.sourcesolver.resolve.result.Resolution; import software.coley.sourcesolver.util.Range; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; /** * Replaces identifiers in a {@link CompilationUnitModel} with new names based on provided {@link Mappings}. * * @author Matt Coley */ @SuppressWarnings("IfCanBeSwitch") public class AstMapper { private final CompilationUnitModel unit; private final Resolver resolver; private final Mappings mappings; /** * @param unit * Unit to map. * @param resolver * Resolver to analyze the unit with. * @param mappings * Mappings to apply. */ public AstMapper(@Nonnull CompilationUnitModel unit, @Nonnull Resolver resolver, @Nonnull Mappings mappings) { this.unit = unit; this.resolver = resolver; this.mappings = mappings; } /** * @return Modified source code based on the provided mappings. */ @Nonnull public String apply() { String source = unit.getInputSource(); // Get all named resolutions and replace them with their mapped alternatives in reverse order. List pairs = new ArrayList<>(); unit.visit(model -> { if (!model.getRange().isUnknown() && model instanceof NamedModel named) { Resolution resolution = model.resolve(resolver); if (!resolution.isUnknown() && !(resolution instanceof PrimitiveResolution)) pairs.add(new NamedResolutions(named, resolution)); } return true; }); for (int i = pairs.size() - 1; i >= 0; i--) { NamedResolutions pair = pairs.get(i); NamedModel named = pair.named(); Resolution resolution = pair.resolution(); if (resolution instanceof ClassResolution classResolution) { source = replacePatternIn(named, source, getSimpleName(classResolution), getSimpleName(getMappedClass(classResolution))); } else if (resolution instanceof FieldResolution fieldResolution) { source = replacePatternIn(named, source, fieldResolution.getFieldEntry().getName(), getSimpleName(getMappedField(fieldResolution))); } else if (resolution instanceof MethodResolution methodResolution) { if (methodResolution.getMethodEntry().getName().equals("")) { // Constructors get replaced as the owner name ClassResolution ownerResolution = methodResolution.getOwnerResolution(); source = replacePatternIn(named, source, getSimpleName(ownerResolution), getSimpleName(getMappedClass(ownerResolution))); } else { source = replacePatternIn(named, source, methodResolution.getMethodEntry().getName(), getSimpleName(getMappedMethod(methodResolution))); } } } // Replace imports of mapped classes & members. IntermediateMappings intermediateMappings = mappings.exportIntermediate(); for (int i = unit.getImports().size() - 1; i >= 0; i--) { ImportModel importModel = unit.getImports().get(i); Resolution resolution = importModel.resolve(resolver); if (resolution instanceof ClassResolution classResolution) { // Single class to map. String mappedName = getMappedClass(classResolution); if (mappedName != null) { String baseName = classResolution.getClassEntry().getName().replace('/', '.'); source = replacePatternIn(importModel, source, baseName, mappedName.replace('/', '.')); } } else if (resolution instanceof MultiClassResolution multiClassResolution) { // Multiple classes to consider with a 'package.*' import. List classesInPackage = multiClassResolution.getClassEntries().stream() .map(ClassEntry::getName) .toList(); Set unmappedClasses = new HashSet<>(); Map mappedClasses = new HashMap<>(); for (String className : classesInPackage) { String mappedClassName = mappings.getMappedClassName(className); if (mappedClassName != null) mappedClasses.put(className, mappedClassName); else unmappedClasses.add(className); } // No classes were mapped, so we're good to go if (mappedClasses.isEmpty()) continue; // Determine what new package names we need to import. int begin = importModel.getRange().begin(); boolean removeExistingPackageImport = unmappedClasses.isEmpty(); if (removeExistingPackageImport) source = source.replace(importModel.getSource(unit), ""); for (String mappedClass : mappedClasses.values()) { if (mappedClass.indexOf('$') < 0) // Skip inner classes source = StringUtil.insert(source, begin, "import " + mappedClass.replace('/', '.') + ";\n"); } } else if (resolution instanceof MultiMemberResolution multiMemberResolution) { ClassEntry ownerEntry = multiMemberResolution.getMemberEntries().getFirst().ownerEntry(); // Rename the imported field/method names if they were mapped. if (importModel.getName().indexOf('*') < 0) { for (ClassMemberPair pair : multiMemberResolution.getMemberEntries()) { MemberEntry memberEntry = pair.memberEntry(); String mappedMemberName; if (memberEntry instanceof FieldEntry fieldEntry) { mappedMemberName = getMappedField(ownerEntry, fieldEntry); } else if (memberEntry instanceof MethodEntry methodEntry) { mappedMemberName = getMappedMethod(ownerEntry, methodEntry); } else { mappedMemberName = null; } if (mappedMemberName != null) { source = replacePatternIn(importModel, source, "." + memberEntry.getName() + ";", "." + mappedMemberName + ";"); break; } } } // If the owning class was renamed, then rename that. String mappedClass = getMappedClass(ownerEntry); if (mappedClass != null) { source = replacePatternIn(importModel, source, ownerEntry.getName().replace('/', '.'), mappedClass.replace('/', '.')); } } } // Replace package if the class got moved to a different package. if (unit.getDeclaredClasses().getFirst().resolve(resolver) instanceof ClassResolution resolution) { String mappedClass = getMappedClass(resolution); if (mappedClass != null) { int slashIndex = mappedClass.lastIndexOf('/'); if (slashIndex > 0) { String mappedPackageName = mappedClass.substring(0, slashIndex); String packageName = unit.getPackage().getName().replace('.', '/'); if (!packageName.equals(mappedPackageName)) { source = source.replace("package " + unit.getPackage().getName() + ";", "package " + mappedPackageName.replace('/', '.') + ";"); } } } } return source; } @Nonnull private String replacePatternIn(@Nonnull Model named, @Nonnull String source, @Nullable String before, @Nullable String after) { if (before != null && after != null && !before.equals(after)) { Range namedRange = extractRelevantRange(named, source); if (namedRange.end() > source.length() || namedRange.isUnknown()) return source; String namedSource = getSource(namedRange, source); String prefix = source.substring(0, namedRange.begin()); String suffix = source.substring(namedRange.end()); String replaced = namedSource.replace(before, after); // TODO: We should ensure only one replacement ever happens. // - We could only replace if the content replaced is surrounded by boundaries. // - Or just validate our range only has one instance of the 'before' text in it if (!replaced.equals(namedSource)) return prefix + replaced + suffix; } return source; } @Nonnull private Range extractRelevantRange(@Nonnull Model model, @Nonnull String source) { if (model instanceof ClassModel classModel) { // The class model is often the FULL range of the file, and we only want the named section. String name = classModel.getName(); int begin = classModel.getRange().begin(); int end = source.indexOf(name, begin) + name.length(); if (end < begin) return Range.UNKNOWN; return new Range(begin, end); } else if (model instanceof VariableModel variableModel) { // The variable range should include only the variable name. // The name doesn't have an associated model, but is after the declared type. String name = variableModel.getName(); int begin = variableModel.getType().getRange().end(); // Enum constants don't have an AST model for their type, so the range is "unknown". // If we're confident this is an enum constant, then we'll make the beginning range the start of the field name instead. if (begin == -1) { ClassModel declaringClass = variableModel.getParentOfType(ClassModel.class); if (declaringClass != null) { if (declaringClass.resolve(resolver) instanceof ClassResolution declaringResolution && AccessFlag.isEnum(declaringResolution.getClassEntry().getAccess())) { Resolution type = variableModel.getType().resolve(resolver); if (type.matches(declaringResolution)) { begin = variableModel.getRange().begin(); } } } } int end = source.indexOf(name, begin) + name.length(); if (end < begin) return Range.UNKNOWN; return new Range(begin, end); } else if (model instanceof MethodModel methodModel) { // The method range should include only the method name. // The name doesn't have an associated model, but is between the return type and first '(' for parameters. String name = methodModel.getName(); int begin = name.equals("") ? methodModel.getRange().begin() : methodModel.getReturnType().getRange().end(); int end = source.indexOf('(', begin); if (end < begin) return Range.UNKNOWN; return new Range(begin, end); } else if (model instanceof NameHoldingModel nameHoldingModel) { // If the holder has an associated model, yield that model's range. if (nameHoldingModel.getNameModel() != null && !nameHoldingModel.getNameModel().getRange().isUnknown()) return nameHoldingModel.getNameModel().getRange(); // Otherwise limit the size of the range based on the first appearance of the named model's name // starting from its reported range beginning point. Also limit by the source length. String name = nameHoldingModel.getName(); Range range = nameHoldingModel.getRange(); int nameBegin = source.indexOf(name, range.begin()); int nameEnd = Math.min(nameBegin + name.length(), source.length()); return new Range(nameBegin, nameEnd); } return model.getRange(); } @Nullable private String getMappedClass(@Nonnull ClassResolution resolution) { return getMappedClass(resolution.getClassEntry()); } @Nullable private String getMappedClass(@Nonnull ClassEntry classEntry) { String name = classEntry.getName(); return mappings.getMappedClassName(name); } @Nullable private String getMappedField(@Nonnull FieldResolution resolution) { return getMappedField(resolution.getOwnerEntry(), resolution.getFieldEntry()); } @Nullable private String getMappedField(@Nonnull ClassEntry ownerEntry, @Nonnull FieldEntry fieldEntry) { String owner = ownerEntry.getName(); String name = fieldEntry.getName(); String desc = fieldEntry.getDescriptor(); return mappings.getMappedFieldName(owner, name, desc); } @Nullable private String getMappedMethod(@Nonnull MethodResolution resolution) { return getMappedMethod(resolution.getOwnerEntry(), resolution.getMethodEntry()); } @Nullable private String getMappedMethod(@Nonnull ClassEntry ownerEntry, @Nonnull MethodEntry methodEntry) { String owner = ownerEntry.getName(); String name = methodEntry.getName(); String desc = methodEntry.getDescriptor(); return mappings.getMappedMethodName(owner, name, desc); } @Nonnull private static String getSimpleName(@Nonnull ClassResolution resolution) { return getSimpleName(resolution.getClassEntry()); } @Nonnull private static String getSimpleName(@Nonnull ClassEntry entry) { return Objects.requireNonNull(getSimpleName(entry.getName())); } @Nullable private static String getSimpleName(@Nullable String name) { if (name == null) return null; return StringUtil.shortenPath(name); } @Nonnull private static String getSource(@Nonnull Range range, @Nonnull String source) { int end = Math.min(source.length(), range.end()); return source.substring(range.begin(), end); } private record NamedResolutions(@Nonnull NamedModel named, @Nonnull Resolution resolution) {} } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/source/AstResolveResult.java ================================================ package software.coley.recaf.services.source; import jakarta.annotation.Nonnull; import software.coley.recaf.path.PathNode; import software.coley.sourcesolver.resolve.result.Resolution; /** * Wrapper for {@link Resolution} values. * * @param isDeclaration * Flag indicating if resolved item is a declaration or reference. * @param path * Resolved value. * * @author Matt Coley * @see ResolverAdapter */ public record AstResolveResult(boolean isDeclaration, @Nonnull PathNode path) { /** * @param path * Path to wrap. * * @return Result of declaration for the given path. */ @Nonnull public static AstResolveResult declared(@Nonnull PathNode path) { return new AstResolveResult(true, path); } /** * @param path * Path to wrap. * * @return Result of reference to the given path. */ @Nonnull public static AstResolveResult reference(@Nonnull PathNode path) { return new AstResolveResult(false, path); } /** * @return Copy of self, as a declaration. */ @Nonnull public AstResolveResult asDeclaration() { return new AstResolveResult(true, path()); } /** * @return Copy of self, as a reference. */ @Nonnull public AstResolveResult asReference() { return new AstResolveResult(false, path()); } /** * @param other * Other result to match {@link #isDeclaration()} state of. * * @return Copy of self, as matching state. */ @Nonnull public AstResolveResult matchDeclarationState(@Nonnull AstResolveResult other) { if (this == other) return this; if (isDeclaration == other.isDeclaration) return this; return other.isDeclaration ? asDeclaration() : asReference(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/source/AstService.java ================================================ package software.coley.recaf.services.source; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.info.AndroidClassInfo; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.InnerClassInfo; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.path.PathNode; import software.coley.recaf.services.Service; import software.coley.recaf.services.mapping.Mappings; import software.coley.recaf.services.workspace.WorkspaceCloseListener; import software.coley.recaf.services.workspace.WorkspaceManager; import software.coley.recaf.services.workspace.WorkspaceOpenListener; import software.coley.recaf.util.ReflectUtil; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.AndroidClassBundle; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.ResourceAndroidClassListener; import software.coley.recaf.workspace.model.resource.ResourceJvmClassListener; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import software.coley.sourcesolver.Parser; import software.coley.sourcesolver.model.CompilationUnitModel; import software.coley.sourcesolver.resolve.Resolver; import software.coley.sourcesolver.resolve.entry.BasicClassEntry; import software.coley.sourcesolver.resolve.entry.BasicFieldEntry; import software.coley.sourcesolver.resolve.entry.BasicMethodEntry; import software.coley.sourcesolver.resolve.entry.ClassEntry; import software.coley.sourcesolver.resolve.entry.EntryPool; import software.coley.sourcesolver.resolve.entry.FieldEntry; import software.coley.sourcesolver.resolve.entry.MethodEntry; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.SortedSet; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; /** * Service for tracking shared data for AST parsing. * * @author Matt Coley */ @ApplicationScoped public class AstService implements Service { public static final String ID = "ast"; /** * Max number of referenced classes to build out from. * See {@link WorkspaceBackedEntryPool#computeEntry(ClassInfo, int)} */ private static final int DEFAULT_TTL = 3; /** * It's rare that we'll need more than one parser, so having a shared reference to a single one for re-use is nice. */ private static final Parser sharedParser; private final AstServiceConfig config; private final WorkspaceManager workspaceManager; private final Cache entryPoolCache = CacheBuilder.newBuilder() .weakKeys() // Intended for the side effect of using '==' for key comparisons over '.equals()' .maximumSize(1) // Users will only ever operate on one workspace, so this is free pruning when they switch .expireAfterWrite(20, TimeUnit.MINUTES) .build(); private EntryPool currentWorkspacePool; static { // We need to have this reflection patch call before we create a parser because of the // tight coupling with the 'jdk.compiler' module internals that the parser has. ReflectUtil.patch(); sharedParser = new Parser(); } @Inject public AstService(@Nonnull AstServiceConfig config, @Nonnull WorkspaceManager workspaceManager) { this.workspaceManager = workspaceManager; this.config = config; ListenerHost host = new ListenerHost(); workspaceManager.addWorkspaceOpenListener(host); workspaceManager.addWorkspaceCloseListener(host); } /** * @return New Java source parser. * * @see #getSharedJavaParser() Shared parser instance. */ @Nonnull public Parser newJavaParser() { return new Parser(); } /** * @return Shared parser instance. */ @Nonnull public Parser getSharedJavaParser() { return sharedParser; } /** * @param parser * Parser to use. * @param source * Java source to parse. * * @return Parsed model of Java source file. */ @Nonnull public CompilationUnitModel parseJava(@Nonnull Parser parser, @Nonnull String source) { return parser.parse(source); } /** * @param unit * Unit to resolve references within. * * @return Resolver to link results to {@link PathNode paths in the current workspace}. */ @Nonnull public ResolverAdapter newJavaResolver(@Nonnull CompilationUnitModel unit) { Workspace workspace = workspaceManager.getCurrent(); return newJavaResolver(workspace, poolFromWorkspace(workspace), unit); } /** * @param workspace * Workspace with classes to link resolved references to. * @param unit * Unit to resolve references within. * * @return Resolver to link results to {@link PathNode paths in the provided workspace}. */ @Nonnull public ResolverAdapter newJavaResolver(@Nonnull Workspace workspace, @Nonnull CompilationUnitModel unit) { return newJavaResolver(workspace, poolFromWorkspace(workspace), unit); } /** * @param workspace * Workspace with classes to link resolved references to. * @param pool * Pool containing class models used by the resolver. * @param unit * Unit to resolve references within. * * @return Resolver to link results to {@link PathNode paths in the provided workspace}. */ @Nonnull private ResolverAdapter newJavaResolver(@Nonnull Workspace workspace, @Nonnull EntryPool pool, @Nonnull CompilationUnitModel unit) { prefillReferencedClasses(workspace, pool, unit); return new ResolverAdapter(workspace, unit, pool); } /** * @param unit * Unit to map. * @param resolver * Resolver to analyze the unit with. * @param mappings * Mappings to apply. * * @return Modified source code based on the provided mappings. */ @Nonnull public String applyMappings(@Nonnull CompilationUnitModel unit, @Nonnull Resolver resolver, @Nonnull Mappings mappings) { return new AstMapper(unit, resolver, mappings).apply(); } /** * Takes classes from the same package that the given unit is from and populates them in the provided entry pool. *
* This is a very important step we must take before using the pool for content resolving. * * @param workspace * Workspace to pull classes from. * @param pool * Pool to dump class models into. * @param unit * Unit to prefill classes for. */ private void prefillReferencedClasses(@Nonnull Workspace workspace, @Nonnull EntryPool pool, @Nonnull CompilationUnitModel unit) { if (pool instanceof WorkspaceBackedEntryPool workspacePool) { String unitPackage = unit.getPackage().getName().replace('.', '/'); SortedSet classesInPackage = unitPackage.isEmpty() ? workspace.findClasses(c -> c.getPackageName() == null) : workspace.findClasses(c -> unitPackage.equals(c.getPackageName())); for (ClassPathNode classPath : classesInPackage) workspacePool.computeEntry(classPath.getValue(), DEFAULT_TTL); } } /** * Maps a workspace to an entry pool instance. *
* It is very important that we re-use pools so that we do not waste time * repetitively filling new pools with the same data. * * @param workspace * Workspace to pull class information from. * * @return Entry pool to store class information for context resolution purposes. */ @Nonnull private EntryPool poolFromWorkspace(@Nonnull Workspace workspace) { EntryPool pool = entryPoolCache.getIfPresent(workspace); if (pool == null) { pool = new WorkspaceBackedEntryPool(workspace); entryPoolCache.put(workspace, pool); } return pool; } @Nonnull @Override public String getServiceId() { return ID; } @Nonnull @Override public AstServiceConfig getServiceConfig() { return config; } /** * Empty pool that yields nothing. */ private static class EmptyEntryPool implements EntryPool { private static final EmptyEntryPool INSTANCE = new EmptyEntryPool(); @Override public void register(@Nonnull ClassEntry entry) { // no-op } @Nullable @Override public ClassEntry getClass(@Nonnull String name) { return null; } @Nonnull @Override public List getClassesInPackage(@Nullable String packageName) { return Collections.emptyList(); } } /** * Pool that pulls classes from a {@link Workspace}. */ private static class WorkspaceBackedEntryPool implements EntryPool, ResourceJvmClassListener, ResourceAndroidClassListener { private final Map cache = new ConcurrentHashMap<>(); private final Workspace workspace; private WorkspaceBackedEntryPool(@Nonnull Workspace workspace) { this.workspace = workspace; // TODO: When we have classes update, we will want to invalidate their child classes // in the cache as well. workspace.getPrimaryResource().addListener(this); } @Override public void register(@Nonnull ClassEntry entry) { cache.put(entry.getName(), entry); } @Nullable @Override public ClassEntry getClass(@Nonnull String name) { return getClass(name, DEFAULT_TTL); } @Nonnull @Override public List getClassesInPackage(@Nullable String packageName) { Stream workspaceEntries = workspace.findClasses(c -> Objects.equals(packageName, c.getPackageName())).stream() .map(p -> computeEntry(p.getValue(), DEFAULT_TTL)); Stream cacheEntries = cache.values().stream() .filter(e -> Objects.equals(packageName, e.getPackageName())); return Stream.concat(workspaceEntries, cacheEntries).toList(); } /** * Lookup a class entry by name, creating it if possible and not already cached. * * @param name * Name of class. * @param ttl * Time-to-live, delegated to {@link #computeEntry(ClassInfo, int)}. * * @return Class entry by the given name, if cached or discoverable in the workspace. */ @Nullable private ClassEntry getClass(@Nonnull String name, int ttl) { ClassEntry entry = cache.get(name); if (entry != null) return entry; ClassPathNode path = workspace.findClass(name); if (path == null) return null; ClassInfo info = path.getValue(); return computeEntry(info, ttl); } /** * @param info * Class model in the workspace to map to a form for context resolution. * @param ttl * Time-to-live, which will prevent construction of new entries when it reaches 0. * * @return Newly created entry modeling the given class. */ @Nullable private ClassEntry computeEntry(@Nonnull ClassInfo info, int ttl) { String className = info.getName(); ClassEntry entry = cache.get(className); if (entry != null) return entry; // Decrement TTL and if it reaches 0 we abort. if (--ttl <= 0) return null; // Construct the class entry model. // NOTE: Parent types are fully computed regardless of TTL. The TTL reduction is used further below. ClassEntry superClass = info.getSuperName() == null ? null : getClass(info.getSuperName()); List fields = info.getFields().stream() .map(f -> (FieldEntry) new BasicFieldEntry(f.getName(), f.getDescriptor(), f.getAccess())) .toList(); List methods = info.getMethods().stream() .map(m -> (MethodEntry) new BasicMethodEntry(m.getName(), m.getDescriptor(), m.getAccess())) .toList(); List innerClasses = new ArrayList<>(); List interfaces = new ArrayList<>(); String outerClassName = info.getOuterClassName(); ClassEntry outerClass = outerClassName != null && outerClassName.startsWith(className + '$') ? cache.get(outerClassName) : null; entry = new BasicClassEntry(className, info.getAccess(), superClass, interfaces, innerClasses, outerClass, fields, methods); register(entry); // Lists of other classes are populated after we put the entry in the pool to prevent entry building cycles. for (InnerClassInfo innerClass : info.getInnerClasses()) { if (innerClass.isExternalReference()) continue; ClassEntry innerClassEntry = getClass(innerClass.getInnerClassName()); if (innerClassEntry != null) innerClasses.add(innerClassEntry); } for (String implemented : info.getInterfaces()) { ClassEntry interfaceEntry = getClass(implemented); if (interfaceEntry != null) interfaces.add(interfaceEntry); } // Ensure all referenced classes are populated in the pool. // Because we only branch out based off a decrementing TTL counter, we should only end up mapping a few levels outwards. // This ensures that when we do any resolving logic with this pool, associated classes are readily available in the pool. // // There is a concern that the edges which fall on TTL==1 won't have their contents "readily available" // but in practice when those missing items are loaded it kicks off another round of pre-emptive loading. // This should result in a UX that is largely smoother overall, especially if the user is interacting with // classes that are "nearby" each other in terms of inheritance or external references. if (info instanceof JvmClassInfo jvmClassInfo) for (String referencedClass : jvmClassInfo.getReferencedClasses()) getClass(referencedClass, ttl); return entry; } @Override public void onNewClass(@Nonnull WorkspaceResource resource, @Nonnull AndroidClassBundle bundle, @Nonnull AndroidClassInfo cls) { cache.remove(cls.getName()); } @Override public void onUpdateClass(@Nonnull WorkspaceResource resource, @Nonnull AndroidClassBundle bundle, @Nonnull AndroidClassInfo oldCls, @Nonnull AndroidClassInfo newCls) { cache.remove(newCls.getName()); } @Override public void onRemoveClass(@Nonnull WorkspaceResource resource, @Nonnull AndroidClassBundle bundle, @Nonnull AndroidClassInfo cls) { cache.remove(cls.getName()); } @Override public void onNewClass(@Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo cls) { cache.remove(cls.getName()); } @Override public void onUpdateClass(@Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo oldCls, @Nonnull JvmClassInfo newCls) { cache.remove(newCls.getName()); } @Override public void onRemoveClass(@Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo cls) { cache.remove(cls.getName()); } } private class ListenerHost implements WorkspaceOpenListener, WorkspaceCloseListener { @Override public void onWorkspaceOpened(@Nonnull Workspace workspace) { currentWorkspacePool = poolFromWorkspace(workspace); } @Override public void onWorkspaceClosed(@Nonnull Workspace workspace) { entryPoolCache.invalidate(workspace); currentWorkspacePool = EmptyEntryPool.INSTANCE; } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/source/AstServiceConfig.java ================================================ package software.coley.recaf.services.source; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link AstService}. * * @author Matt Coley */ @ApplicationScoped public class AstServiceConfig extends BasicConfigContainer implements ServiceConfig { @Inject public AstServiceConfig() { super(ConfigGroups.SERVICE_ANALYSIS, AstService.ID + CONFIG_SUFFIX); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/source/ResolverAdapter.java ================================================ package software.coley.recaf.services.source; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.path.ClassMemberPathNode; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.path.DirectoryPathNode; import software.coley.recaf.workspace.model.Workspace; import software.coley.sourcesolver.model.AnnotationExpressionModel; import software.coley.sourcesolver.model.AssignmentExpressionModel; import software.coley.sourcesolver.model.ClassModel; import software.coley.sourcesolver.model.CompilationUnitModel; import software.coley.sourcesolver.model.ErroneousModel; import software.coley.sourcesolver.model.MethodBodyModel; import software.coley.sourcesolver.model.MethodModel; import software.coley.sourcesolver.model.Model; import software.coley.sourcesolver.model.ModifiersModel; import software.coley.sourcesolver.model.TypeModel; import software.coley.sourcesolver.model.VariableModel; import software.coley.sourcesolver.resolve.BasicResolver; import software.coley.sourcesolver.resolve.entry.ClassEntry; import software.coley.sourcesolver.resolve.entry.ClassMemberPair; import software.coley.sourcesolver.resolve.entry.EntryPool; import software.coley.sourcesolver.resolve.entry.FieldEntry; import software.coley.sourcesolver.resolve.entry.MethodEntry; import software.coley.sourcesolver.resolve.result.ClassResolution; import software.coley.sourcesolver.resolve.result.FieldResolution; import software.coley.sourcesolver.resolve.result.MethodResolution; import software.coley.sourcesolver.resolve.result.MultiClassResolution; import software.coley.sourcesolver.resolve.result.MultiMemberResolution; import software.coley.sourcesolver.resolve.result.PackageResolution; import software.coley.sourcesolver.resolve.result.Resolution; import software.coley.sourcesolver.resolve.result.Resolutions; import java.util.List; /** * Adapts {@link Resolution} values into our {@link AstResolveResult}. * * @author Matt Coley */ public class ResolverAdapter extends BasicResolver { private final Workspace workspace; /** * @param workspace * Workspace to pull classes from in order to adapt {@link Resolution} values into our {@link AstResolveResult} model. * @param unit * Root element model. * @param pool * Pool to access class metadata. */ public ResolverAdapter(@Nonnull Workspace workspace, @Nonnull CompilationUnitModel unit, @Nonnull EntryPool pool) { super(unit, pool); this.workspace = workspace; } /** * Marks the declared class in the compilation unit as being resolved to the given class. * * @param cls * Class that represents the code outlined by the compilation unit. */ public void setClassContext(@Nonnull ClassInfo cls) { ClassModel model = getUnit().getDeclaredClasses().getFirst(); ClassEntry entry = getPool().getClass(cls.getName()); if (model != null && entry != null) setDeclaredClass(model, entry); } /** * @param position * Absolute position in the source code of the item we want to resolve. * * @return Our mapped resolution result which points to a path in the workspace for the resolved content. */ @Nullable public AstResolveResult resolveThenAdapt(int position) { // Find the deepest model at position. Model model = getUnit(); while (true) { it: { for (Model child : model.getChildren()) { if (child.getRange().isWithin(position) && !(child instanceof ErroneousModel)) { model = child; break it; } } break; } } // Resolve the content at the given position then adapt it. Resolution resolution = resolveAt(position, model); return adapt(resolution, model); } /** * @param resolution * Resolution to adapt. * @param target * Target model that was the item being resolved. * * @return Our mapped resolution result which points to a path in the workspace for the resolved content. */ @Nullable public AstResolveResult adapt(@Nonnull Resolution resolution, @Nonnull Model target) { if (resolution.isUnknown()) return null; else if (resolution instanceof ClassResolution classResolution) { String name = classResolution.getClassEntry().getName(); ClassPathNode path = workspace.findClass(name); if (path == null) return null; // If the target *is the class* then it is a declaration. if (target instanceof ClassModel && target.resolve(this).matches(resolution)) return AstResolveResult.declared(path); // If the target is within a method body, it is always a reference. // Same for: // - Contents of annotation expressions like "@MyAnno(foo = bar)" // - Contents of assignments in places like fields // - Contents of type names // - The class name is just a name model, not a type model so if (target.getParentOfType(MethodBodyModel.class) != null) return AstResolveResult.reference(path); if (target.getParentOfType(AnnotationExpressionModel.class) != null) return AstResolveResult.reference(path); if (target.getParentOfType(AssignmentExpressionModel.class) != null) return AstResolveResult.reference(path); if (target.getParentOfType(TypeModel.class) != null) return AstResolveResult.reference(path); ClassModel parentClassDeclaration = target.getParentOfType(ClassModel.class); if (parentClassDeclaration != null && parentClassDeclaration.resolve(this).matches(resolution)) return AstResolveResult.declared(path); return AstResolveResult.reference(path); } else if (resolution instanceof FieldResolution fieldResolution) { String ownerName = fieldResolution.getOwnerEntry().getName(); ClassPathNode ownerPath = workspace.findClass(ownerName); if (ownerPath == null) return null; FieldEntry fieldEntry = fieldResolution.getFieldEntry(); ClassMemberPathNode fieldPath = ownerPath.child(fieldEntry.getName(), fieldEntry.getDescriptor()); if (fieldPath == null) return null; // Determine if it's a declaration or reference. // - Check if any declared class's fields have the target model in their range for (ClassModel declaredClass : getUnit().getRecursiveChildrenOfType(ClassModel.class)) for (VariableModel field : declaredClass.getFields()) if (field.getRange().isWithin(target.getRange().begin())) return AstResolveResult.declared(fieldPath); return AstResolveResult.reference(fieldPath); } else if (resolution instanceof MethodResolution methodResolution) { String ownerName = methodResolution.getOwnerEntry().getName(); ClassPathNode ownerPath = workspace.findClass(ownerName); if (ownerPath == null) return null; MethodEntry methodEntry = methodResolution.getMethodEntry(); ClassMemberPathNode methodPath = ownerPath.child(methodEntry.getName(), methodEntry.getDescriptor()); if (methodPath == null) return null; // The model we resolved is a declaration if: // - It is a 'MethodModel' that resolves to the same method // - The declaring class must define a method of the same name/type for (ClassModel declaredClass : getUnit().getRecursiveChildrenOfType(ClassModel.class)) if (target instanceof MethodModel targetMethod && targetMethod.resolve(this).equals(methodResolution) && declaredClass.resolve(this) instanceof ClassResolution declaredClassResolution && declaredClassResolution.matches(methodResolution.getOwnerResolution()) && methodResolution.matches(declaredClassResolution.getDeclaredMemberResolution(methodEntry))) { return AstResolveResult.declared(methodPath); } else if (target instanceof ModifiersModel && target.getParent() instanceof MethodModel parentMethod && parentMethod.isStaticInitializer()) { return AstResolveResult.declared(methodPath); } return AstResolveResult.reference(methodPath); } else if (resolution instanceof MultiMemberResolution multiMemberResolution) { // Used in static star import contexts such as 'Math.*' or single method static imports such as 'Math.min'. // For stars, yield the class. For single members, yield the member. List memberEntries = multiMemberResolution.getMemberEntries(); ClassMemberPair firstMember = memberEntries.getFirst(); String firstClassName = firstMember.ownerEntry().getName(); if (memberEntries.size() == 1) { // Single member return adapt(Resolutions.ofMember(firstMember), target); } else if (memberEntries.size() > 1) { // Multiple members ClassPathNode path = workspace.findClass(firstClassName); if (path != null) return AstResolveResult.reference(path); } } else if (resolution instanceof MultiClassResolution multiClassResolution) { // Used in start import contexts such as 'java.util.*' so yield the package they're all residing within. String firstClassName = multiClassResolution.getClassEntries().getFirst().getName(); int slashIndex = firstClassName.lastIndexOf('/'); if (slashIndex > 0) { String packageName = firstClassName.substring(0, slashIndex); DirectoryPathNode path = workspace.findPackage(packageName); if (path != null) return AstResolveResult.reference(path); } } else if (resolution instanceof PackageResolution packageResolution) { String packageName = packageResolution.getPackageName(); if (packageName != null) { DirectoryPathNode path = workspace.findPackage(packageName); if (path != null) return AstResolveResult.reference(path); } } // TODO: To support operating on method parameters we need to update source-solver // to have a resolution model on parameters. Then we may also want to have a generic // local variable solver for similar capabilities. This would let us create mappings // in the UI for variables which would be nice. return null; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/text/TextFormatConfig.java ================================================ package software.coley.recaf.services.text; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.observables.ObservableBoolean; import software.coley.observables.ObservableInteger; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.BasicConfigValue; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.util.EscapeUtil; import software.coley.recaf.util.StringUtil; /** * Config for text formatting. * * @author Matt Coley */ @ApplicationScoped public class TextFormatConfig extends BasicConfigContainer { public static final String ID = "text-format"; private final ObservableBoolean escape = new ObservableBoolean(true); private final ObservableBoolean shorten = new ObservableBoolean(true); private final ObservableInteger maxLength = new ObservableInteger(120); @Inject public TextFormatConfig() { super(ConfigGroups.SERVICE_UI, ID + CONFIG_SUFFIX); // Add values addValue(new BasicConfigValue<>("escape", boolean.class, escape)); addValue(new BasicConfigValue<>("shorten", boolean.class, shorten)); addValue(new BasicConfigValue<>("max-length", int.class, maxLength)); } /** * @return {@code true} to escape text with {@link #filter(String)}. */ @Nonnull public ObservableBoolean getDoEscape() { return escape; } /** * @return {@code true} to shorten path text with {@link #filter(String)}. */ @Nonnull public ObservableBoolean getDoShortenPaths() { return shorten; } /** * @return {@code true} to limit the length of text with {@link #filter(String)}. */ @Nonnull public ObservableInteger getMaxLength() { return maxLength; } /** * @param string * Some text to filter. * * @return Filtered text based on current config. */ public String filter(@Nullable String string) { return filter(string, true, true, true); } /** * @param string * Some text to filter. * @param shortenPath * Apply path shortening filtering. * @param escape * Apply escaping. * @param maxLength * Apply max length cap. * * @return Filtered text based on current config. */ public String filter(@Nullable String string, boolean shortenPath, boolean escape, boolean maxLength) { if (string == null) return null; if (shortenPath) string = filterShorten(string); if (escape) string = filterEscape(string); if (maxLength) string = filterMaxLength(string); return string; } /** * @param string * Some text to filter. * * @return Filtered text based on current config. */ public String filterShorten(@Nullable String string) { if (string != null && shorten.getValue()) return StringUtil.shortenPath(string); return string; } /** * @param string * Some text to filter. * * @return Filtered text based on current config. */ public String filterEscape(@Nullable String string) { if (string != null && escape.getValue()) return EscapeUtil.escapeStandardAndUnicodeWhitespace(string); return string; } /** * @param string * Some text to filter. * * @return Filtered text based on current config. */ public String filterMaxLength(@Nullable String string) { if (string != null && maxLength.getValue() != null) { int maxLengthPrim = maxLength.getValue(); if (string.length() > maxLengthPrim) string = string.substring(0, maxLengthPrim) + "…"; } return string; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/transform/CancellableTransformationFeedback.java ================================================ package software.coley.recaf.services.transform; /** * Feedback that allows cancelling a transformation. * * @author Matt Coley */ public class CancellableTransformationFeedback implements TransformationFeedback { private boolean canceled; /** * Mark transformation as cancelled. */ public void cancel() { canceled = true; } @Override public boolean hasRequestedCancellation() { return canceled; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/transform/ClassTransformer.java ================================================ package software.coley.recaf.services.transform; import jakarta.annotation.Nonnull; import java.util.Collections; import java.util.Set; /** * Outlines base transformation information such as the name and list of any dependencies. * * @author Matt Coley */ public interface ClassTransformer { /** * @return Name of the transformer. */ @Nonnull String name(); /** * @return {@code true} if this transformer should not be applied to following passes if in the current pass it reports no work being done. * {@code false} if this transformer should run in all passes. */ default boolean pruneAfterNoWork() { return false; } /** * @return Set of transformer classes that are recommended to be run before this one, but not strictly required. * * @see #recommendedSuccessors() * @see #dependencies() */ @Nonnull default Set> recommendedPredecessors() { return Collections.emptySet(); } /** * @return Set of transformer classes that are recommended to be run after this one, but not strictly required. * * @see #recommendedPredecessors() */ @Nonnull default Set> recommendedSuccessors() { return Collections.emptySet(); } /** * @return Set of transformer classes that must run before this one. * * @see #recommendedPredecessors() */ @Nonnull default Set> dependencies() { return Collections.emptySet(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/transform/JvmClassTransformer.java ================================================ package software.coley.recaf.services.transform; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import org.objectweb.asm.tree.ClassNode; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; /** * Outlines the base JVM transformation contract. *

* NOTE: Internal transformers must be {@link Dependent} scoped so that they do not get proxied by CDI. * See {@link JvmTransformerContext#getJvmTransformer(Class)}. * * @author Matt Coley */ public interface JvmClassTransformer extends ClassTransformer { /** * Used to do any workspace-scope setup actions before transformations occur. * * @param context * Transformation context for access to other transformers and recording class changes. * @param workspace * Workspace containing classes to transform. */ default void setup(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace) {} /** * Implementations can {@link #dependencies() depend on other transformers} and access them * via {@link JvmTransformerContext#getJvmTransformer(Class)}. This may be useful in cases where you want to have * one transformer act as a shared data-storage between multiple transformers. *

* To record changes to the given {@code classInfo} you can: *

    *
  • Record a {@link ClassNode} via {@link JvmTransformerContext#setNode(JvmClassBundle, JvmClassInfo, ClassNode)}
  • *
  • Record bytecode via {@link JvmTransformerContext#setBytecode(JvmClassBundle, JvmClassInfo, byte[])}
  • *
* * @param context * Transformation context for access to other transformers and recording class changes. * @param workspace * Workspace containing the class. * @param resource * Resource containing the class. * @param bundle * Bundle containing the class. * @param initialClassState * The initial state of the class to transform. * Do not use this as the base of any transformation. * Use {@link JvmTransformerContext#getNode(JvmClassBundle, JvmClassInfo)} * or {@link JvmTransformerContext#getBytecode(JvmClassBundle, JvmClassInfo)} * to look up the current transformed state of the class. * * @throws TransformationException * When the class cannot be transformed for any reason. */ void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClassState) throws TransformationException; } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/transform/JvmTransformResult.java ================================================ package software.coley.recaf.services.transform; import software.coley.recaf.info.JvmClassInfo; /** * Intermediate holder of transformations of workspace JVM classes. * * @author Matt Coley */ public interface JvmTransformResult extends TransformResult {} ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/transform/JvmTransformerContext.java ================================================ package software.coley.recaf.services.transform; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.MethodNode; import org.objectweb.asm.tree.analysis.Frame; import org.slf4j.Logger; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.path.PathNodes; import software.coley.recaf.path.ResourcePathNode; import software.coley.recaf.services.deobfuscation.transform.generic.DeadCodeRemovingTransformer; import software.coley.recaf.services.deobfuscation.transform.generic.FrameRemovingTransformer; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.services.mapping.aggregate.AggregatedMappings; import software.coley.recaf.util.analysis.ReAnalyzer; import software.coley.recaf.util.analysis.ReInterpreter; import software.coley.recaf.util.analysis.lookup.BasicGetStaticLookup; import software.coley.recaf.util.analysis.lookup.BasicInvokeStaticLookup; import software.coley.recaf.util.analysis.lookup.BasicInvokeVirtualLookup; import software.coley.recaf.util.analysis.lookup.GetFieldLookup; import software.coley.recaf.util.analysis.lookup.GetStaticLookup; import software.coley.recaf.util.analysis.lookup.InvokeStaticLookup; import software.coley.recaf.util.analysis.lookup.InvokeVirtualLookup; import software.coley.recaf.util.analysis.value.ReValue; import software.coley.recaf.util.visitors.FrameSkippingVisitor; import software.coley.recaf.util.visitors.WorkspaceClassWriter; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.IdentityHashMap; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; /** * Context for holding a number of JVM class transformers and shared state for transformation. * * @author Matt Coley */ public class JvmTransformerContext { private static final Logger logger = Logging.get(JvmTransformerContext.class); private final Map, JvmClassTransformer> transformerMap; private final AggregatedMappings mappings; private final Set classesToRemove = ConcurrentHashMap.newKeySet(); private final Map classData = new ConcurrentHashMap<>(); private final Set recomputeFrameClasses = ConcurrentHashMap.newKeySet(); private final ThreadLocal transformerDidWork = ThreadLocal.withInitial(() -> false); private final Workspace workspace; private final WorkspaceResource resource; private Supplier getFieldLookupSupplier = () -> null; private Supplier getStaticLookupSupplier = BasicGetStaticLookup::new; private Supplier invokeVirtualLookupSupplier = BasicInvokeVirtualLookup::new; private Supplier invokeStaticLookupSupplier = BasicInvokeStaticLookup::new; private boolean dropFaultyClasses; // For debugging, not exposed publicly. /** * Constructs a new context from an array of transformers. * * @param workspace * Workspace containing the classes to transform. * @param resource * Resource in the workspace containing classes to transform. Should always be the {@link Workspace#getPrimaryResource()}. * @param transformers * Transformers to associate with this context. */ public JvmTransformerContext(@Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull JvmClassTransformer... transformers) { this(workspace, resource, Arrays.asList(transformers)); } /** * Constructs a new context from a collection of transformers. * * @param workspace * Workspace containing the classes to transform. * @param resource * Resource in the workspace containing classes to transform. Should always be the {@link Workspace#getPrimaryResource()}. * @param transformers * Transformers to associate with this context. */ public JvmTransformerContext(@Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull Collection transformers) { this.transformerMap = buildMap(transformers); this.workspace = workspace; this.resource = resource; // We will use aggregated mappings for the reverse-mapping utility it offers. // Some transformers that aim to provide mappings will find this very handy. mappings = new AggregatedMappings(workspace); } /** * Builds the map of initial transformed class paths to their final transformed states. *
* The map keys are existing workspace paths the respective classes. *
* The map values are classes post-transformation, without any mappings applied. * * @param inheritanceGraph * Inheritance graph tied to the workspace the transformed classes belong to. * * @throws TransformationException * When the classes cannot be written back to {@code byte[]} likely due to frame computation problems. */ @Nonnull protected Map buildChangeMap(@Nonnull InheritanceGraph inheritanceGraph) throws TransformationException { ResourcePathNode resourcePath = PathNodes.resourcePath(workspace, resource); Map map = new IdentityHashMap<>(); for (JvmClassData data : classData.values()) { if (data.isDirty()) { if (data.node != null) { // Emit bytecode from the current node boolean recompute = recomputeFrameClasses.contains(data.node.name); int flags = recompute && !dropFaultyClasses ? ClassWriter.COMPUTE_FRAMES : 0; ClassReader reader = data.initialClass.getClassReader(); // Copy const-pool + bootstrap methods ClassWriter writer = new WorkspaceClassWriter(inheritanceGraph, reader, flags); try { if (recompute) data.node.accept(new FrameSkippingVisitor(writer)); else data.node.accept(writer); // Update output map byte[] modifiedBytes = writer.toByteArray(); JvmClassInfo modifiedClass = data.initialClass.toJvmClassBuilder() .adaptFrom(modifiedBytes) .build(); ClassPathNode classPath = resourcePath.child(data.bundle) .child(modifiedClass.getPackageName()) .child(modifiedClass); map.put(classPath, modifiedClass); } catch (Throwable t) { if (dropFaultyClasses) { logger.warn("Error writing class '{}', skipping", data.initialClass.getName(), t); continue; } throw new TransformationException("ClassNode --> byte[] failed for class '" + data.node.name + "'", t); } } else { // Update output map if the bytecode is not the same as the initial state byte[] bytecode = data.getBytecode(); if (!Arrays.equals(bytecode, data.initialClass.getBytecode())) { JvmClassInfo modifiedClass = data.initialClass.toJvmClassBuilder() .adaptFrom(bytecode) .build(); ClassPathNode classPath = resourcePath.child(data.bundle) .child(modifiedClass.getPackageName()) .child(modifiedClass); map.put(classPath, modifiedClass); } } } } return map; } /** * @param inheritanceGraph * Inheritance graph of workspace. * @param cls * Name of class defining a method to analyze. * @param method * Method to analyze. * * @return Analyzed frames of the given method. * * @throws TransformationException * When the analyzer throws an exception when computing the frames of the given method. */ @Nonnull public Frame[] analyze(@Nonnull InheritanceGraph inheritanceGraph, @Nonnull ClassNode cls, @Nonnull MethodNode method) throws TransformationException { try { ReAnalyzer analyzer = newAnalyzer(inheritanceGraph, cls, method); return analyzer.analyze(cls.name, method); } catch (Throwable t) { throw new TransformationException("Error encountered when computing method frames", t); } } /** * @param inheritanceGraph * Inheritance graph of workspace. * @param cls * Name of class defining a method to analyze. * @param method * Method to analyze. * * @return An analyzer for the given method. */ @Nonnull public ReAnalyzer newAnalyzer(@Nonnull InheritanceGraph inheritanceGraph, @Nonnull ClassNode cls, @Nonnull MethodNode method) { ReInterpreter interpreter = newInterpreter(inheritanceGraph); return new ReAnalyzer(interpreter); } /** * @param inheritanceGraph * Inheritance graph of workspace. * * @return An interpreter for handling instruction execution. */ @Nonnull public ReInterpreter newInterpreter(@Nonnull InheritanceGraph inheritanceGraph) { ReInterpreter interpreter = new ReInterpreter(inheritanceGraph); interpreter.setGetFieldLookup(getFieldLookupSupplier.get()); interpreter.setGetStaticLookup(getStaticLookupSupplier.get()); interpreter.setInvokeVirtualLookup(invokeVirtualLookupSupplier.get()); interpreter.setInvokeStaticLookup(invokeStaticLookupSupplier.get()); return interpreter; } /** * Utility to invoke {@link DeadCodeRemovingTransformer} for a given method. * Requires the transformer to be provided to this context. * * @param declaringClass * Class declaring the method to clean up. * @param method * Method with dead code to remove. * * @return {@code true} when there were changes as a result of dead code removal. * {@code false} for no changes being made to the passed method. * * @throws TransformationException * When the {@link DeadCodeRemovingTransformer} was not provided to this context, * or if dead code removal encountered an error. */ public boolean pruneDeadCode(@Nonnull ClassNode declaringClass, @Nonnull MethodNode method) throws TransformationException { return getJvmTransformer(DeadCodeRemovingTransformer.class).prune(declaringClass, method); } /** * @param bundle * Bundle containing the class. * @param info * The class's model in the workspace. * * @return {@code true} when the context currently has the class represented as a node (vs raw {@code byte[]}) */ public boolean isNode(@Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo info) { JvmClassData data = getJvmClassData(bundle, info); return data.node != null; } /** * Gets the current ASM node representation of the given class. *

* Transformers can update the "current" state of the node via * {@link #setBytecode(JvmClassBundle, JvmClassInfo, byte[])} or * {@link #setNode(JvmClassBundle, JvmClassInfo, ClassNode)}. * * @param bundle * Bundle containing the class. * @param info * The class's model in the workspace. * * @return The current tracked/transformed {@link ClassNode} for the associated class. * * @see #setNode(JvmClassBundle, JvmClassInfo, ClassNode) */ @Nonnull public ClassNode getNode(@Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo info) { return getJvmClassData(bundle, info).getOrCreateNode(); } /** * Gets the current bytecode of the given class. *

* Transformers can update the "current" state of the bytecode via * {@link #setBytecode(JvmClassBundle, JvmClassInfo, byte[])} or * {@link #setNode(JvmClassBundle, JvmClassInfo, ClassNode)}. * * @param bundle * Bundle containing the class. * @param info * The class's model in the workspace. * * @return The current tracked/transformed bytecode for the associated class. * * @see #setBytecode(JvmClassBundle, JvmClassInfo, byte[]) */ @Nonnull public byte[] getBytecode(@Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo info) { return getJvmClassData(bundle, info).getBytecode(); } /** * Updates the transformed state of a class by recording an ASM node representation of the class. * * @param bundle * Bundle containing the class. * @param info * The class's model in the workspace. * @param node * ASM node representation of the class to store. * * @see #getNode(JvmClassBundle, JvmClassInfo) */ public void setNode(@Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo info, @Nonnull ClassNode node) { transformerDidWork.set(true); getJvmClassData(bundle, info).setNode(node); } /** * Updates the transformed state of a class by recording new bytecode of the class. * * @param bundle * Bundle containing the class. * @param info * The class's model in the workspace. * This does not need to reflect the updated state of the bytecode, it is strictly used for keying/lookups. * @param bytecode * Bytecode of the class to store. * * @see #getBytecode(JvmClassBundle, JvmClassInfo) */ public void setBytecode(@Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo info, @Nonnull byte[] bytecode) { transformerDidWork.set(true); getJvmClassData(bundle, info).setBytecode(bytecode); } /** * Marks a class for removal in the workspace. * * @param info * The class model in the workspace. * * @see #getClassesToRemove() */ public void markClassForRemoval(@Nonnull JvmClassInfo info) { markClassForRemoval(info.getName()); } /** * Marks a class for removal in the workspace. * * @param name * Internal class name. * * @see #getClassesToRemove() */ public void markClassForRemoval(@Nonnull String name) { classesToRemove.add(name); } /** * @return Names of classes marked for removal. * * @see #markClassForRemoval(JvmClassInfo) */ @Nonnull public Set getClassesToRemove() { return Collections.unmodifiableSet(classesToRemove); } /** * Clears any transformations applied to the given class. * * @param bundle * Bundle containing the class. * @param info * The class's model in the workspace. */ public void clear(@Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo info) { JvmClassData data = getJvmClassData(bundle, info); data.setBytecode(data.initialClass.getBytecode()); data.dirty = false; } /** * Called by transformers that have more thorough changes applied to classes that likely violate existing frames. * * @param className * Name of class to recompute frames for when building the change map. */ public void setRecomputeFrames(@Nonnull String className) { recomputeFrameClasses.add(className); } /** * Transformers that aim to rename classes, fields, and methods should register the desired mappings * here, and they will be applied after all other transformations are applied. * * @return Mappings to apply upon transformation completion. */ @Nonnull public AggregatedMappings getMappings() { return mappings; } /** * @return Workspace containing the classes to transform. */ @Nonnull public Workspace getWorkspace() { return workspace; } /** * Get the {@link JvmClassTransformer} instance associated with this context, or throw an exception if no such * transformer is registered. If you are looking for an optional lookup use: {@link #getOptionalJvmTransformer(Class)}. * * @param key * Transformer class. * @param * Transformer type. * * @return Shared instance of the transformer within this context. * * @throws TransformationException * When the transformer was not found within this context. */ @Nonnull public T getJvmTransformer(Class key) throws TransformationException { T transformer = getOptionalJvmTransformer(key); if (transformer == null) throw new TransformationException("Transformation context attempted lookup of class '" + key.getSimpleName() + "' but did not have an associated entry"); return transformer; } /** * Get the {@link JvmClassTransformer} instance associated with this context, if it is registered. * * @param key * Transformer class. * @param * Transformer type. * * @return Shared instance of the transformer within this context, * or {@code null} if no such transformer is registered to this context. */ @Nullable @SuppressWarnings("unchecked") public T getOptionalJvmTransformer(Class key) { // NOTE: Any Recaf-defined transformer must be @Dependent so that CDI doesn't give you proxy wrappers // of the class. Our map is identity based, and if you do 'get(MyClass.class)' and we end up storing the // proxy wrapper, then the lookup will fail even though the transformer is seemingly registered. JvmClassTransformer transformer = transformerMap.get(key); if (transformer == null) return null; return (T) transformer; } /** * @param supplier * Supplier for {@link GetFieldLookup} to be used with {@link #newAnalyzer(InheritanceGraph, ClassNode, MethodNode)}. */ public void setGetFieldLookupSupplier(@Nullable Supplier supplier) { if (supplier == null) supplier = () -> null; getFieldLookupSupplier = supplier; } /** * @param supplier * Supplier for {@link GetStaticLookup} to be used with {@link #newAnalyzer(InheritanceGraph, ClassNode, MethodNode)}. */ public void setGetStaticLookupSupplier(@Nullable Supplier supplier) { if (supplier == null) supplier = () -> null; getStaticLookupSupplier = supplier; } /** * @param supplier * Supplier for {@link InvokeVirtualLookup} to be used with {@link #newAnalyzer(InheritanceGraph, ClassNode, MethodNode)}. */ public void setInvokeVirtualLookupSupplier(@Nullable Supplier supplier) { if (supplier == null) supplier = () -> null; invokeVirtualLookupSupplier = supplier; } /** * @param supplier * Supplier for {@link InvokeStaticLookup} to be used with {@link #newAnalyzer(InheritanceGraph, ClassNode, MethodNode)}. */ public void setInvokeStaticLookupSupplier(@Nullable Supplier supplier) { if (supplier == null) supplier = () -> null; invokeStaticLookupSupplier = supplier; } /** * Called before any transformer operates with this context. *
* Clears any state associated with the operation of transformers. * * @see #didTransformerDoWork() */ protected void resetTransformerTracking() { // Any transformation application should call this before the transformer methods operate on data. transformerDidWork.set(false); } /** * Used to check if a {@link ClassTransformer} did work after its {@code transform} has been executed with * this context being used as a parameter. * * @return {@code true} if the last transformer ran did work with this context. */ protected boolean didTransformerDoWork() { return transformerDidWork.get(); } @Nonnull private JvmClassData getJvmClassData(@Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo info) { return classData.computeIfAbsent(info.getName(), ignored -> new JvmClassData(bundle, info)); } @Nonnull private static Map, JvmClassTransformer> buildMap(@Nonnull Collection transformers) { Map, JvmClassTransformer> map = new IdentityHashMap<>(); for (JvmClassTransformer transformer : transformers) map.put(transformer.getClass(), transformer); return Collections.unmodifiableMap(map); } /** * Container of per-class transformation state. */ private class JvmClassData { private final JvmClassBundle bundle; private final JvmClassInfo initialClass; private volatile byte[] bytecode; private volatile ClassNode node; private boolean dirty; /** * @param bundle * Bundle containing the class. * @param initialClass * Initial state of the class before transformation. */ public JvmClassData(@Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo initialClass) { this.initialClass = initialClass; this.bundle = bundle; bytecode = initialClass.getBytecode(); } /** * @return Node representation of the {@link #getBytecode() current bytecode}. */ @Nonnull public ClassNode getOrCreateNode() { if (node == null) { synchronized (this) { if (node == null) { node = new ClassNode(); int readerFlags = getOptionalJvmTransformer(FrameRemovingTransformer.class) == null ? 0 : ClassReader.SKIP_FRAMES; // Can bypass reading frames if this transformer is active. new ClassReader(bytecode).accept(node, readerFlags); } } } // We always give back a copy so actions taken on this node are not affecting the cached instance // unless a transformer explicitly commits the change. ClassNode nodeCopy = new ClassNode(); node.accept(nodeCopy); return nodeCopy; } /** * @return Current bytecode of the class. */ @Nonnull public byte[] getBytecode() { if (bytecode == null) { synchronized (this) { if (bytecode == null) { ClassWriter writer = new ClassWriter(0); node.accept(writer); bytecode = writer.toByteArray(); } } } return bytecode; } /** * @param node * Current node representation to set for this class. */ public void setNode(@Nonnull ClassNode node) { synchronized (this) { this.node = node; bytecode = null; // Invalidate bytecode state dirty = true; } } /** * @param bytecode * Current bytecode to set for this class. */ public void setBytecode(@Nonnull byte[] bytecode) { synchronized (this) { this.bytecode = bytecode; node = null; // Invalidate node state dirty = true; } } /** * @return {@code true} when changes have been applied to this class. */ public boolean isDirty() { return dirty; } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/transform/TransformResult.java ================================================ package software.coley.recaf.services.transform; import jakarta.annotation.Nonnull; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.services.mapping.IntermediateMappings; import java.util.Collection; import java.util.Map; import java.util.Set; /** * Intermediate holder of transformations of workspace classes. * * @author Matt Coley */ public interface TransformResult { /** * Puts the transformed classes into the associated workspace. * You can inspect the state of transformed classes before this via {@link #getTransformedClasses()}. */ void apply(); /** * @return Mappings to apply. */ @Nonnull IntermediateMappings getMappingsToApply(); /** * Shows which classes have been modified by which transformers. *

* The paths to the classes in the map are to the original * state of the class and do not have modified {@link ClassInfo} contents. * * @return Map of transformers to the paths of classes they have modified. */ @Nonnull Map, Collection> getModifiedClassesPerTransformer(); /** * @return Map of classes, to their maps of transformer-associated exceptions. * Empty if transformation was a complete success (no failures). */ @Nonnull Map, Throwable>> getTransformerFailures(); /** * This map associates workspace paths to classes to the resulting transformed class models. * The transformed models do not have {@link #getMappingsToApply() mappings} applied to them, * as that process occurs during the {@link #apply()} operation. * * @return Map of class paths to the original classes, to the resulting transformed class models. */ @Nonnull Map getTransformedClasses(); /** * @return Set of paths to classes that will be removed. */ @Nonnull Set getClassesToRemove(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/transform/TransformationApplier.java ================================================ package software.coley.recaf.services.transform; import jakarta.annotation.Nonnull; import software.coley.collections.Sets; import software.coley.recaf.analytics.logging.DebuggingLogger; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.path.BundlePathNode; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.path.PathNodes; import software.coley.recaf.path.ResourcePathNode; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.services.mapping.IntermediateMappings; import software.coley.recaf.services.mapping.MappingApplier; import software.coley.recaf.services.mapping.MappingResults; import software.coley.recaf.util.threading.ThreadPoolFactory; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import static software.coley.collections.Unchecked.cast; import static software.coley.collections.Unchecked.checkedForEach; /** * Applies transformations to workspaces. * * @author Matt Coley * @see TransformationManager */ public class TransformationApplier { private static final DebuggingLogger logger = Logging.get(TransformationApplier.class); private final TransformationManager transformationManager; private final TransformationApplierConfig transformApplyConfig; private final InheritanceGraph inheritanceGraph; private final MappingApplier mappingApplier; private final Workspace workspace; private int maxPasses = 1; /** * @param transformationManager * Manager to pull transformer instances from. * @param transformApplyConfig * Transformation applier config. * @param inheritanceGraph * Inheritance graph to use for frame computation (Some transformers will trigger this). * @param mappingApplier * Mapping applier to update workspace with mappings registered by transformers. * @param workspace * Workspace with classes to transform. */ public TransformationApplier(@Nonnull TransformationManager transformationManager, @Nonnull TransformationApplierConfig transformApplyConfig, @Nonnull InheritanceGraph inheritanceGraph, @Nonnull MappingApplier mappingApplier, @Nonnull Workspace workspace) { this.transformationManager = transformationManager; this.transformApplyConfig = transformApplyConfig; this.inheritanceGraph = inheritanceGraph; this.mappingApplier = mappingApplier; this.workspace = workspace; } /** * @return Maximum number of times to repeat transformations. */ public int getMaxPasses() { return Math.max(1, maxPasses); } /** * @param maxPasses * Maximum number of times to repeat transformations */ public void setMaxPasses(int maxPasses) { this.maxPasses = maxPasses; } /** * @param transformerClasses * JVM class transformers to run. * * @return Result container with details about the transformation, including any failures, the transformed classes, * and the option to apply the transformations to the workspace. * * @throws TransformationException * When transformation cannot be run for any reason. */ @Nonnull public JvmTransformResult transformJvm(@Nonnull List> transformerClasses) throws TransformationException { return transformJvm(transformerClasses, TransformationFeedback.DEFAULT); } /** * @param transformerClasses * JVM class transformers to run. * @param feedback * Feedback to report transformation progress to, and control which JVM classes are transformed. * * @return Result container with details about the transformation, including any failures, the transformed classes, * and the option to apply the transformations to the workspace. * * @throws TransformationException * When transformation cannot be run for any reason. */ @Nonnull public JvmTransformResult transformJvm(@Nonnull List> transformerClasses, @Nonnull TransformationFeedback feedback) throws TransformationException { // Build transformer visitation order. TransformerQueue queue = buildQueue(cast(transformerClasses)); // Map to hold transformation errors for each class:transformer. Map, Throwable>> transformJvmFailures = Collections.synchronizedMap(new IdentityHashMap<>()); // Map to hold transformers to the paths of classes they have modified. Map, Collection> transformerToModifiedClasses = Collections.synchronizedMap(new IdentityHashMap<>()); // Build the transformer context and apply all transformations in order. List transformers = queue.getTransformers(); WorkspaceResource resource = workspace.getPrimaryResource(); ResourcePathNode resourcePath = PathNodes.resourcePath(workspace, resource); JvmTransformerContext context = new JvmTransformerContext(workspace, resource, transformers); for (JvmClassTransformer transformer : transformers) { try { transformer.setup(context, workspace); } catch (Throwable t) { // If setup fails, abort the transformation String message = "Transformer '" + transformer.name() + "' failed on setup"; logger.error(message, t); throw new TransformationException(message, t); } } AtomicInteger finalPass = new AtomicInteger(); List prunedTransformers = new ArrayList<>(); try (ExecutorService service = transformApplyConfig.doParallelize().getValue() ? ThreadPoolFactory.newFixedThreadPool("transform-apply") : ThreadPoolFactory.newSingleThreadExecutor("transform-apply")) { resource.jvmAllClassBundleStreamRecursive().forEach(bundle -> { List> tasks = new ArrayList<>(bundle.size()); BundlePathNode bundlePathNode = resourcePath.child(bundle); for (int pass = 1; pass <= getMaxPasses(); pass++) { finalPass.set(pass); AtomicBoolean anyWorkDone = new AtomicBoolean(false); for (JvmClassTransformer transformer : transformers) { AtomicBoolean transformerWorkDone = new AtomicBoolean(false); final int currentPass = pass; // Transformers can be run in parallel per each pass across all classes in the bundle. tasks.clear(); for (JvmClassInfo cls : bundle) tasks.add(() -> { // Skip if transformation has been cancelled if (feedback.hasRequestedCancellation()) return null; // Skip if the class does not pass the predicate if (!feedback.shouldTransform(workspace, resource, bundle, cls, transformer, currentPass)) return null; try { context.resetTransformerTracking(); transformer.transform(context, workspace, resource, bundle, cls); boolean didWork = context.didTransformerDoWork(); if (didWork) { // Transformer modified this class, record the interaction anyWorkDone.set(true); transformerWorkDone.set(true); Collection paths = transformerToModifiedClasses.computeIfAbsent(transformer.getClass(), t -> Collections.synchronizedSet(Collections.newSetFromMap(new IdentityHashMap<>()))); // Only keep one path (since we may have repeated passes) synchronized (paths) { if (paths.stream().noneMatch(p -> p.getValue().getName().equals(cls.getName()))) { ClassPathNode path = bundlePathNode.child(cls.getPackageName()).child(cls); paths.add(path); } } feedback.onTransformed(workspace, resource, bundle, cls, transformer, currentPass); } else { feedback.onTransformedWithoutWork(workspace, resource, bundle, cls, transformer, currentPass); } logger.debugging(l -> l.debug("Pass {}: Transformer {} didWork={}", currentPass, transformer.getClass().getSimpleName(), didWork)); } catch (Throwable t) { logger.error("Transformer '{}' failed on class '{}'", transformer.name(), cls.getName(), t); feedback.onTransformFailure(workspace, resource, bundle, cls, transformer,currentPass, t); ClassPathNode path = bundlePathNode.child(cls.getPackageName()).child(cls); var transformerToThrowable = transformJvmFailures.computeIfAbsent(path, p -> Collections.synchronizedMap(new IdentityHashMap<>())); transformerToThrowable.put(transformer.getClass(), t); } return null; }); // Invoke and wait for all classes in this bundle to be visited/transformed. try { service.invokeAll(tasks); } catch (InterruptedException ex) { throw new RuntimeException("Interrupt", ex); } // If a transformer is prunable (they no longer execute after a full pass without any work completed) // schedule it for removal so that it will not be executed in following passes. if (!transformerWorkDone.get() && transformer.pruneAfterNoWork()) { logger.debug("Pruning transformer '{}' after pass {} completed with no work done", transformer.name(), pass); prunedTransformers.add(transformer); } } // Remove pruned transformers. transformers.removeAll(prunedTransformers); // Break if this transformer has done no work has been done this pass. if (!anyWorkDone.get()) break; } }); feedback.onCompletion(); } catch (RuntimeException ex) { // Handle the interrupt runtime exception seen a few lines up. throw new TransformationException("Unexpected runtime exception", ex); } // Update the workspace contents with the transformation results Map transformedJvmClasses = context.buildChangeMap(inheritanceGraph); logger.debug("Computed transformations with {} transformers, affecting {} classes after {} passes", transformerClasses.size(), transformedJvmClasses.size(), finalPass.get()); return new JvmTransformResult() { @Nonnull @Override public Map, Throwable>> getTransformerFailures() { return transformJvmFailures; } @Nonnull @Override public Map getTransformedClasses() { return transformedJvmClasses; } @Nonnull @Override public Set getClassesToRemove() { return context.getClassesToRemove().stream() .map(workspace::findJvmClass) .filter(Objects::nonNull) .collect(Collectors.toSet()); } @Nonnull @Override public IntermediateMappings getMappingsToApply() { return context.getMappings(); } @Nonnull @Override public Map, Collection> getModifiedClassesPerTransformer() { return transformerToModifiedClasses; } @Override public void apply() { // Dump transformed classes into the workspace checkedForEach(transformedJvmClasses, (path, cls) -> { JvmClassBundle bundle = path.getValueOfType(JvmClassBundle.class); if (bundle != null) bundle.put(cls); }, (path, cls, t) -> logger.error("Exception thrown handling transform application", t)); // Delete classes that are marked for removal for (ClassPathNode path : getClassesToRemove()) { JvmClassBundle bundle = path.getValueOfType(JvmClassBundle.class); if (bundle != null) bundle.remove(path.getValue().getName()); } // Apply mappings if they exist IntermediateMappings mappings = context.getMappings(); if (!mappings.isEmpty()) { MappingResults results = mappingApplier.applyToPrimaryResource(mappings); results.apply(); } } }; } @Nonnull private TransformerQueue buildQueue(@Nonnull List> transformerClasses) throws TransformationException { TransformerQueue queue = new TransformerQueue(); for (Class transformerClass : transformerClasses) insert(queue, transformerClass, Collections.emptySet()); return queue; } private void insert(@Nonnull TransformerQueue queue, @Nonnull Class transformerClass, @Nonnull Set> dependants) throws TransformationException { // Abort if a cycle is detected if (dependants.contains(transformerClass)) throw new TransformationException("Transformer dependency cycle detected with '" + transformerClass.getSimpleName() + "'"); // Create the transformer and its dependencies // - Dependencies first // - Then the transformer ClassTransformer transformer; if (JvmClassTransformer.class.isAssignableFrom(transformerClass)) { Class jvmTransformerClass = cast(transformerClass); transformer = transformationManager.newJvmTransformer(jvmTransformerClass); } else { throw new TransformationException("Unsupported transformer class type: " + transformerClass); } for (Class dependency : transformer.dependencies()) if (!queue.containsType(dependency)) insert(queue, dependency, Sets.add(dependants, transformerClass)); queue.add(transformer); } /** * Wrapper holding which transformers to run. */ private static class TransformerQueue { private final List transformers = new ArrayList<>(); private final List> transformerTypes = new ArrayList<>(); /** * @param transformer * Transformer to add to the queue. */ private void add(@Nonnull ClassTransformer transformer) { transformers.add(transformer); transformerTypes.add(transformer.getClass()); } /** * @param transformerClass * Transformer type to check for, * * @return {@code true} when the queue already has a transformer of that type registered. */ private boolean containsType(@Nonnull Class transformerClass) { return transformerTypes.contains(transformerClass); } /** * @param * Inferred transformer type. * * @return List of registered transformers. */ @Nonnull private List getTransformers() { return cast(transformers); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/transform/TransformationApplierConfig.java ================================================ package software.coley.recaf.services.transform; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.observables.ObservableBoolean; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.BasicConfigValue; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link TransformationApplierService}. * * @author Matt Coley */ @ApplicationScoped public class TransformationApplierConfig extends BasicConfigContainer implements ServiceConfig { private final ObservableBoolean parallelize = new ObservableBoolean(true); @Inject public TransformationApplierConfig() { super(ConfigGroups.SERVICE_TRANSFORM, TransformationApplierService.SERVICE_ID + CONFIG_SUFFIX); addValue(new BasicConfigValue<>("parallelize", boolean.class, parallelize)); } /** * @return {@code true} to enable parallelization of transformer applications. */ @Nonnull public ObservableBoolean doParallelize() { return parallelize; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/transform/TransformationApplierService.java ================================================ package software.coley.recaf.services.transform; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.slf4j.Logger; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.services.Service; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.services.inheritance.InheritanceGraphService; import software.coley.recaf.services.mapping.MappingApplier; import software.coley.recaf.services.mapping.MappingApplierService; import software.coley.recaf.services.workspace.WorkspaceManager; import software.coley.recaf.workspace.model.Workspace; import java.util.Objects; /** * Service offering the creation of {@link TransformationApplier transformation appliers} for workspaces. * * @author Matt Coley * @see TransformationManager * @see TransformationApplier */ @ApplicationScoped public class TransformationApplierService implements Service { public static final String SERVICE_ID = "transformation-applier"; private static final Logger logger = Logging.get(TransformationApplierService.class); private final TransformationManager transformationManager; private final InheritanceGraphService graphService; private final MappingApplierService mappingService; private final WorkspaceManager workspaceManager; private final TransformationApplierConfig config; @Inject public TransformationApplierService(@Nonnull TransformationManager transformationManager, @Nonnull InheritanceGraphService graphService, @Nonnull MappingApplierService mappingService, @Nonnull WorkspaceManager workspaceManager, @Nonnull TransformationApplierConfig config) { this.graphService = graphService; this.mappingService = mappingService; this.workspaceManager = workspaceManager; this.transformationManager = transformationManager; this.config = config; } /** * @param workspace * Workspace to apply transformations within. * * @return Transformation applier for the given workspace. */ @Nonnull public TransformationApplier newApplier(@Nonnull Workspace workspace) { // Optimal case for current workspace using the shared workspace inheritance graph if (workspace == workspaceManager.getCurrent()) { InheritanceGraph graphNotCreated = Objects.requireNonNull(graphService.getCurrentWorkspaceInheritanceGraph(), "Graph not created"); MappingApplier mappingApplier = Objects.requireNonNull(mappingService.inCurrentWorkspace(), "Mapping applier not created"); return newApplier(workspace, graphNotCreated, mappingApplier); } // Need to make a new graph for the given workspace InheritanceGraph inheritanceGraph = graphService.newInheritanceGraph(workspace); MappingApplier mappingApplier = mappingService.inWorkspace(workspace); return newApplier(workspace, inheritanceGraph, mappingApplier); } /** * @return Transformation applier for the {@link WorkspaceManager#getCurrent() current workspace} * or {@code null} if no workspace is currently open. */ @Nullable public TransformationApplier newApplierForCurrentWorkspace() { if (!workspaceManager.hasCurrentWorkspace()) return null; InheritanceGraph inheritanceGraph = Objects.requireNonNull(graphService.getCurrentWorkspaceInheritanceGraph(), "Graph not created"); MappingApplier mappingApplier = Objects.requireNonNull(mappingService.inCurrentWorkspace(), "Mapping applier not created"); Workspace workspace = workspaceManager.getCurrent(); return newApplier(workspace, inheritanceGraph, mappingApplier); } /** * @param workspace * Workspace to apply transformations within. * @param inheritanceGraph * Inheritance graph for the given workspace. * @param mappingApplier * Mapping applier for the given workspace. * * @return Transformation applier for the given workspace. */ @Nonnull private TransformationApplier newApplier(@Nonnull Workspace workspace, @Nonnull InheritanceGraph inheritanceGraph, @Nonnull MappingApplier mappingApplier) { return new TransformationApplier(transformationManager, config, inheritanceGraph, mappingApplier, workspace); } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public TransformationApplierConfig getServiceConfig() { return config; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/transform/TransformationException.java ================================================ package software.coley.recaf.services.transform; /** * Thrown for any problem that prevents transformation operations. * * @author Matt Coley */ public class TransformationException extends Exception { /** * @param message * Detail message explaining the transformation failure. */ public TransformationException(String message) { super(message); } /** * @param message * Detail message explaining the transformation failure. * @param cause * Root problem/cause. */ public TransformationException(String message, Throwable cause) { super(message, cause); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/transform/TransformationFeedback.java ================================================ package software.coley.recaf.services.transform; import jakarta.annotation.Nonnull; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.ClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; /** * Outline of transformation feedback capabilities. Allows for: *

    *
  • In-progress transformation cancellation
  • *
  • Filter classes transformed
  • *
  • Notifying transformation progress
  • *
* * @author Matt Coley * @see CancellableTransformationFeedback Basic cancellable implementation. */ public interface TransformationFeedback { /** * Default implementation that runs transformations to completion. */ TransformationFeedback DEFAULT = new TransformationFeedback() { }; /** * @return {@code true} to request {@link TransformationApplier} stops handling input to end the transformation early. * {@code false} to continue the transformation. */ default boolean hasRequestedCancellation() { return false; } /** * Called before a class is transformed. * * @param workspace * Workspace containing the class. * @param resource * Resource containing the class. * @param bundle * Bundle containing the class. * @param classInfo * The class to transform. * @param transformer * Transformer to apply. * @param pass * The current transformation pass. * * @return {@code true} to allow the class to be transformed. * {@code false} to skip transforming the given class. */ default boolean shouldTransform(@Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull ClassBundle bundle, @Nonnull ClassInfo classInfo, @Nonnull ClassTransformer transformer, int pass) { return true; } /** * Called when a class has been successfully transformed. * * @param workspace * Workspace containing the class. * @param resource * Resource containing the class. * @param bundle * Bundle containing the class. * @param classInfo * The original class transformed. * @param transformer * Transformer applied. * @param pass * The current transformation pass. */ default void onTransformed(@Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull ClassBundle bundle, @Nonnull ClassInfo classInfo, @Nonnull ClassTransformer transformer, int pass) {} /** * Called when a class passed transformation, but no work was done. * * @param workspace * Workspace containing the class. * @param resource * Resource containing the class. * @param bundle * Bundle containing the class. * @param classInfo * The original class transformed. * @param transformer * Transformer applied. * @param pass * The current transformation pass. */ default void onTransformedWithoutWork(@Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull ClassBundle bundle, @Nonnull ClassInfo classInfo, @Nonnull ClassTransformer transformer, int pass) {} /** * Called when a transformation on a class fails. * * @param workspace * Workspace containing the class. * @param resource * Resource containing the class. * @param bundle * Bundle containing the class. * @param classInfo * The original class transformed. * @param transformer * Transformer applied. * @param pass * The current transformation pass. * @param error * The exception thrown during transformation. */ default void onTransformFailure(@Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull ClassBundle bundle, @Nonnull ClassInfo classInfo, @Nonnull ClassTransformer transformer, int pass, @Nonnull Throwable error) {} /** * Called when the transformation completes. */ default void onCompletion() {} } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/transform/TransformationManager.java ================================================ package software.coley.recaf.services.transform; import com.google.common.annotations.VisibleForTesting; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.spi.Bean; import jakarta.inject.Inject; import org.slf4j.Logger; import software.coley.collections.Unchecked; import software.coley.recaf.Bootstrap; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.services.Service; import java.util.HashSet; import java.util.IdentityHashMap; import java.util.Map; import java.util.Set; import java.util.function.Supplier; /** * Manager for tracking transformers. * * @author Matt Coley */ @ApplicationScoped public class TransformationManager implements Service { public static final String SERVICE_ID = "transformation-manager"; private static final Logger logger = Logging.get(TransformationManager.class); private final Map, Supplier> jvmTransformerSuppliers = new IdentityHashMap<>(); private final Set> thirdPartyJvmTransformers = new HashSet<>(); private final TransformationManagerConfig config; /** * Constructor for pulling transformer instances from the Recaf CDI container. * * @param config * Manager config. * @param jvmTransformers * CDI provider of JVM transformer implementations. */ @Inject public TransformationManager(@Nonnull TransformationManagerConfig config, @Nonnull Instance jvmTransformers) { this.config = config; for (Instance.Handle handle : jvmTransformers.handles()) { Bean bean = handle.getBean(); Class transformerClass = Unchecked.cast(bean.getBeanClass()); // To differentiate our built-in transformers from any plugin-provided ones, we register them directly here // rather than using the register method below. jvmTransformerSuppliers.put(transformerClass, () -> { // Even though our transformers may be @Dependent scoped, we need to do a new lookup each time we want // a new instance to get our desired scope behavior. If we re-use the instance handle that is injected // here then even @Dependent scoped beans will yield the same instance again and again. return Bootstrap.get().get(transformerClass); }); } } /** * Constructor for testing with pre-defined sets of transformers. * * @param jvmTransformerSuppliers * Map of transformer classes to suppliers that generate instances of those classes. */ @VisibleForTesting public TransformationManager(@Nonnull Map, Supplier> jvmTransformerSuppliers) { this.jvmTransformerSuppliers.putAll(jvmTransformerSuppliers); this.config = new TransformationManagerConfig(); } /** * Register a new {@link JvmClassTransformer}. * * @param transformerClass * Class of transformer to register. * @param transformerSupplier * Supplier of transformer instances. * @param * Transformer type. */ public void registerJvmClassTransformer(@Nonnull Class transformerClass, @Nonnull Supplier transformerSupplier) { // Only update the map if this is a new transformer. if (thirdPartyJvmTransformers.add(transformerClass)) jvmTransformerSuppliers.put(transformerClass, Unchecked.cast(transformerSupplier)); } /** * Unregister a previously-registered {@link JvmClassTransformer}. * * @param transformerClass * Class of transformer to unregister. * @param * Transformer type. */ public void unregisterJvmClassTransformer(@Nonnull Class transformerClass) { // Only allow unregistering of third-party transformers. if (thirdPartyJvmTransformers.remove(transformerClass)) jvmTransformerSuppliers.remove(transformerClass); } /** * @return Set of registered {@link JvmClassTransformer} classes. */ @Nonnull public Set> getJvmClassTransformers() { return jvmTransformerSuppliers.keySet(); } /** * @return Set of third-party registered {@link JvmClassTransformer} classes. */ @Nonnull public Set> getThirdPartyJvmTransformers() { return thirdPartyJvmTransformers; } @Nonnull @SuppressWarnings("unchecked") public T newJvmTransformer(@Nonnull Class type) throws TransformationException { try { Supplier supplier = jvmTransformerSuppliers.get(type); if (supplier == null) throw new TransformationException("Requested transformer supplier for type '" + type.getSimpleName() + "' but no associated supplier was registered"); return (T) supplier.get(); } catch (Throwable t) { throw new TransformationException("Requested transformer supplier for type '" + type.getSimpleName() + "' could not be instantiated", t); } } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public TransformationManagerConfig getServiceConfig() { return config; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/transform/TransformationManagerConfig.java ================================================ package software.coley.recaf.services.transform; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link TransformationManager}. * * @author Matt Coley */ @ApplicationScoped public class TransformationManagerConfig extends BasicConfigContainer implements ServiceConfig { @Inject public TransformationManagerConfig() { super(ConfigGroups.SERVICE_TRANSFORM, TransformationManager.SERVICE_ID + CONFIG_SUFFIX); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/tutorial/TutorialConfig.java ================================================ package software.coley.recaf.services.tutorial; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.observables.ObservableBoolean; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.BasicConfigValue; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.util.DevDetection; import software.coley.recaf.util.ExcludeFromJacocoGeneratedReport; /** * Config for in-application tutorials. * * @author Matt Coley */ @ApplicationScoped @ExcludeFromJacocoGeneratedReport(justification = "Tutorial is for UI usage only, and is not testable in a meaningful way.") public class TutorialConfig extends BasicConfigContainer { private final ObservableBoolean acknowledgedSaveWithErrors = new ObservableBoolean(DevDetection.isDevEnv()); private final ObservableBoolean finishedTutorial = new ObservableBoolean(DevDetection.isDevEnv()); @Inject public TutorialConfig() { super(ConfigGroups.SERVICE_UI, "tutorial" + CONFIG_SUFFIX); addValue(new BasicConfigValue<>("acknowledged-save-with-errors", boolean.class, acknowledgedSaveWithErrors, true)); addValue(new BasicConfigValue<>("finished-tutorial", boolean.class, finishedTutorial, true)); } /** * @return Flag indicating if the user has acknowledged they cannot save with errors. */ @Nonnull public ObservableBoolean getAcknowledgedSaveWithErrors() { return acknowledgedSaveWithErrors; } /** * @return Flag indicating if the user has finished the interactive tutorial. */ @Nonnull public ObservableBoolean getFinishedTutorial() { return finishedTutorial; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/tutorial/TutorialWorkspace.java ================================================ package software.coley.recaf.services.tutorial; import jakarta.annotation.Nonnull; import software.coley.recaf.util.ExcludeFromJacocoGeneratedReport; import software.coley.recaf.workspace.model.BasicWorkspace; /** * Specialized tutorial workspace. * * @author Matt Coley */ @ExcludeFromJacocoGeneratedReport(justification = "Tutorial is for UI usage only, and is not testable in a meaningful way.") public class TutorialWorkspace extends BasicWorkspace { /** * @param primary * Tutorial resource. */ public TutorialWorkspace(@Nonnull TutorialWorkspaceResource primary) { super(primary); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/tutorial/TutorialWorkspaceResource.java ================================================ package software.coley.recaf.services.tutorial; import jakarta.annotation.Nonnull; import software.coley.recaf.util.ExcludeFromJacocoGeneratedReport; import software.coley.recaf.workspace.model.resource.BasicWorkspaceResource; import software.coley.recaf.workspace.model.resource.WorkspaceResourceBuilder; /** * Specialized tutorial resource. * * @author Matt Coley */ @ExcludeFromJacocoGeneratedReport(justification = "Tutorial is for UI usage only, and is not testable in a meaningful way.") public class TutorialWorkspaceResource extends BasicWorkspaceResource { public static final String COMMENT_KEY = "tutorial-resource-key"; /** * @param builder * Resource builder. */ public TutorialWorkspaceResource(@Nonnull WorkspaceResourceBuilder builder) { super(builder); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/BasicWorkspaceManager.java ================================================ package software.coley.recaf.services.workspace; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.Dependent; import jakarta.enterprise.inject.Produces; import jakarta.inject.Inject; import org.slf4j.Logger; import software.coley.collections.Lists; import software.coley.collections.Unchecked; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.behavior.PrioritySortable; import software.coley.recaf.workspace.model.EmptyWorkspace; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.WorkspaceModificationListener; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.Comparator; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; /** * Basic workspace manager implementation. * * @author Matt Coley */ @ApplicationScoped public class BasicWorkspaceManager implements WorkspaceManager { private static final Logger logger = Logging.get(BasicWorkspaceManager.class); private final List closeConditions = new CopyOnWriteArrayList<>(); private final List openListeners = new CopyOnWriteArrayList<>(); private final List closeListeners = new CopyOnWriteArrayList<>(); private final List defaultModificationListeners = new CopyOnWriteArrayList<>(); private final WorkspaceManagerConfig config; private Workspace current; @Inject public BasicWorkspaceManager(@Nonnull WorkspaceManagerConfig config) { this.config = config; } @Nonnull @Override @Produces @Dependent public Workspace getCurrent() { if (current == null) return EmptyWorkspace.get(); return current; } @Override public boolean hasCurrentWorkspace() { return current != null; } @Override public void setCurrentIgnoringConditions(Workspace workspace) { Workspace currentWorkspace = current; if (currentWorkspace != null) { currentWorkspace.close(); Unchecked.checkedForEach(closeListeners, listener -> listener.onWorkspaceClosed(currentWorkspace), (listener, t) -> logger.error("Exception thrown when closing workspace", t)); } current = workspace; if (workspace != null) { defaultModificationListeners.forEach(workspace::addWorkspaceModificationListener); Unchecked.checkedForEach(openListeners, listener -> listener.onWorkspaceOpened(workspace), (listener, t) -> logger.error("Exception thrown by when opening workspace", t)); } } @Nonnull @Override public List getWorkspaceCloseConditions() { return closeConditions; } @Override public void addWorkspaceCloseCondition(@Nonnull WorkspaceCloseCondition condition) { PrioritySortable.add(closeConditions, condition); } @Override public void removeWorkspaceCloseCondition(@Nonnull WorkspaceCloseCondition condition) { closeConditions.remove(condition); } @Nonnull @Override public List getWorkspaceOpenListeners() { return openListeners; } @Override public void addWorkspaceOpenListener(@Nonnull WorkspaceOpenListener listener) { PrioritySortable.add(openListeners, listener); } @Override public void removeWorkspaceOpenListener(@Nonnull WorkspaceOpenListener listener) { openListeners.remove(listener); } @Nonnull @Override public List getWorkspaceCloseListeners() { return closeListeners; } @Override public void addWorkspaceCloseListener(@Nonnull WorkspaceCloseListener listener) { PrioritySortable.add(closeListeners, listener); } @Override public void removeWorkspaceCloseListener(@Nonnull WorkspaceCloseListener listener) { closeListeners.remove(listener); } @Nonnull @Override public List getDefaultWorkspaceModificationListeners() { return defaultModificationListeners; } @Override public void addDefaultWorkspaceModificationListeners(@Nonnull WorkspaceModificationListener listener) { PrioritySortable.add(defaultModificationListeners, listener); } @Override public void removeDefaultWorkspaceModificationListeners(@Nonnull WorkspaceModificationListener listener) { defaultModificationListeners.remove(listener); addDefaultWorkspaceModificationListeners(new WorkspaceModificationListener() { @Override public void onAddLibrary(@Nonnull Workspace workspace, @Nonnull WorkspaceResource library) { // Supporting library added to workspace } @Override public void onRemoveLibrary(@Nonnull Workspace workspace, @Nonnull WorkspaceResource library) { // Supporting library removed from workspace } }); } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public WorkspaceManagerConfig getServiceConfig() { return config; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/WorkspaceCloseCondition.java ================================================ package software.coley.recaf.services.workspace; import jakarta.annotation.Nonnull; import software.coley.recaf.behavior.PrioritySortable; import software.coley.recaf.workspace.model.Workspace; /** * Condition applied to {@link WorkspaceManager} to prevent closure of an active workspace for when * {@link WorkspaceManager#setCurrent(Workspace)} is called. * * @author Matt Coley */ public interface WorkspaceCloseCondition extends PrioritySortable { /** * @param current * Current workspace. * * @return {@code true} when the operation is allowed. */ boolean canClose(@Nonnull Workspace current); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/WorkspaceCloseListener.java ================================================ package software.coley.recaf.services.workspace; import jakarta.annotation.Nonnull; import software.coley.recaf.behavior.PrioritySortable; import software.coley.recaf.workspace.model.Workspace; /** * Listener for when old workspaces are closed. * * @author Matt Coley */ public interface WorkspaceCloseListener extends PrioritySortable { /** * Called when {@link WorkspaceManager#setCurrent(Workspace)} passes and a prior workspace is removed. * * @param workspace * New workspace assigned. */ void onWorkspaceClosed(@Nonnull Workspace workspace); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/WorkspaceManager.java ================================================ package software.coley.recaf.services.workspace; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.Dependent; import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.Produces; import software.coley.recaf.services.Service; import software.coley.recaf.workspace.model.BasicWorkspace; import software.coley.recaf.workspace.model.EmptyWorkspace; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.WorkspaceModificationListener; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.Collections; import java.util.List; /** * Service outline for managing workspace importing/exporting and the currently open workspace. * * @author Matt Coley */ public interface WorkspaceManager extends Service { String SERVICE_ID = "workspace-manager"; /** * Exposes the current workspace directly and through CDI. * Any {@link Instance} in the Recaf application should point to this value. * * @return The current active workspace. Will be {@link EmptyWorkspace} when no active workspace is open. * * @see #hasCurrentWorkspace() */ @Nonnull @Produces @Dependent Workspace getCurrent(); /** * The {@link #getCurrent()} method will yield an empty workspace when nothing is currently open. * This method should be used to check if no workspace is actually open. * * @return {@code true} when there is a valid current workspace. * {@code false} when no workspace is open. */ boolean hasCurrentWorkspace(); /** * @param workspace * New workspace to set as the active workspace. * * @return {@code true} when the workspace assignment is a success. * {@code false} if the assignment was blocked for some reason. */ default boolean setCurrent(@Nullable Workspace workspace) { Workspace current = getCurrent(); if (!hasCurrentWorkspace()) { // If there is no current workspace, then just assign it. setCurrentIgnoringConditions(workspace); return true; } else if (getWorkspaceCloseConditions().stream() .allMatch(condition -> condition.canClose(current))) { // Otherwise, check if the conditions allow for closing the prior workspace. // If so, then assign the new workspace. setCurrentIgnoringConditions(workspace); return true; } // Workspace closure conditions not met, assignment denied. return false; } /** * Effectively {@link #setCurrent(Workspace)} except any blocking conditions are bypassed. *
* Listeners for open/close events must be called when implementing this. * * @param workspace * New workspace to set as the active workspace. */ void setCurrentIgnoringConditions(@Nullable Workspace workspace); /** * Closes the current workspace. * * @return {@code true} on success. */ default boolean closeCurrent() { if (hasCurrentWorkspace()) return setCurrent(null); return true; } /** * @param primary * Primary resource for editing. * * @return New workspace of resource. */ @Nonnull default Workspace createWorkspace(@Nonnull WorkspaceResource primary) { return createWorkspace(primary, Collections.emptyList()); } /** * @param primary * Primary resource for editing. * @param libraries * Supporting resources. * * @return New workspace of resources */ @Nonnull default Workspace createWorkspace(@Nonnull WorkspaceResource primary, @Nonnull List libraries) { return new BasicWorkspace(primary, libraries); } /** * @return Conditions in the manager that can prevent {@link #setCurrent(Workspace)} from going through. */ @Nonnull List getWorkspaceCloseConditions(); /** * @param condition * Condition to add. */ void addWorkspaceCloseCondition(@Nonnull WorkspaceCloseCondition condition); /** * @param condition * Condition to remove. */ void removeWorkspaceCloseCondition(@Nonnull WorkspaceCloseCondition condition); /** * @return Listeners for when a new workspace is assigned as the current one. */ @Nonnull List getWorkspaceOpenListeners(); /** * @param listener * Listener to add. */ void addWorkspaceOpenListener(@Nonnull WorkspaceOpenListener listener); /** * @param listener * Listener to remove. */ void removeWorkspaceOpenListener(@Nonnull WorkspaceOpenListener listener); /** * @return Listeners for when the current workspace is removed as being current. */ @Nonnull List getWorkspaceCloseListeners(); /** * @param listener * Listener to add. */ void addWorkspaceCloseListener(@Nonnull WorkspaceCloseListener listener); /** * @param listener * Listener to remove. */ void removeWorkspaceCloseListener(@Nonnull WorkspaceCloseListener listener); /** * @return Listeners to add to any workspace passed to {@link #setCurrent(Workspace)}. */ @Nonnull List getDefaultWorkspaceModificationListeners(); /** * @param listener * Listener to add. */ void addDefaultWorkspaceModificationListeners(@Nonnull WorkspaceModificationListener listener); /** * @param listener * Listener to remove. */ void removeDefaultWorkspaceModificationListeners(@Nonnull WorkspaceModificationListener listener); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/WorkspaceManagerConfig.java ================================================ package software.coley.recaf.services.workspace; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link WorkspaceManager} * * @author Matt Coley */ @ApplicationScoped public class WorkspaceManagerConfig extends BasicConfigContainer implements ServiceConfig { @Inject public WorkspaceManagerConfig() { super(ConfigGroups.SERVICE_IO, WorkspaceManager.SERVICE_ID + CONFIG_SUFFIX); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/WorkspaceOpenListener.java ================================================ package software.coley.recaf.services.workspace; import jakarta.annotation.Nonnull; import software.coley.recaf.behavior.PrioritySortable; import software.coley.recaf.workspace.model.Workspace; /** * Listener for when new workspaces are opened. * * @author Matt Coley */ public interface WorkspaceOpenListener extends PrioritySortable { /** * Called when {@link WorkspaceManager#setCurrent(Workspace)} passes. * * @param workspace * New workspace assigned. */ void onWorkspaceOpened(@Nonnull Workspace workspace); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/WorkspaceProcessingConfig.java ================================================ package software.coley.recaf.services.workspace; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link WorkspaceProcessingService}. * * @author Matt Coley */ @ApplicationScoped public class WorkspaceProcessingConfig extends BasicConfigContainer implements ServiceConfig { @Inject public WorkspaceProcessingConfig() { super(ConfigGroups.SERVICE_TRANSFORM, WorkspaceProcessingService.SERVICE_ID + CONFIG_SUFFIX); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/WorkspaceProcessingService.java ================================================ package software.coley.recaf.services.workspace; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.spi.Bean; import jakarta.inject.Inject; import org.slf4j.Logger; import software.coley.collections.Unchecked; import software.coley.recaf.Bootstrap; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.cdi.EagerInitialization; import software.coley.recaf.services.Service; import software.coley.recaf.workspace.model.Workspace; import java.util.IdentityHashMap; import java.util.Map; import java.util.function.Supplier; /** * Applies all discovered {@link WorkspaceProcessor} instances to {@link Workspace} instances upon loading them via * {@link WorkspaceManager#setCurrent(Workspace)}. * * @author Matt Coley * @see WorkspaceProcessor Processor type to implement. */ @EagerInitialization @ApplicationScoped public class WorkspaceProcessingService implements Service { public static final String SERVICE_ID = "workspace-processing"; private static final Logger logger = Logging.get(WorkspaceProcessingService.class); private final Map, Supplier> processorSuppliers = new IdentityHashMap<>(); private final WorkspaceProcessingConfig config; /** * @param workspaceManager * Manager to facilitate listening to new opened workspaces. * @param config * Service config. * @param processors * Discovered processors to apply. */ @Inject public WorkspaceProcessingService(@Nonnull WorkspaceManager workspaceManager, @Nonnull WorkspaceProcessingConfig config, @Nonnull Instance processors) { this.config = config; for (Instance.Handle handle : processors.handles()) { Bean bean = handle.getBean(); Class processorClass = Unchecked.cast(bean.getBeanClass()); processorSuppliers.put(processorClass, () -> { // Even though our processors may be @Dependent scoped, we need to do a new lookup each time we want // a new instance to get our desired scope behavior. If we re-use the instance handle that is injected // here then even @Dependent scoped beans will yield the same instance again and again. return Bootstrap.get().get(processorClass); }); } // Apply processors when new workspace is opened workspaceManager.addWorkspaceOpenListener(this::processWorkspace); } /** * @param processorClass * Class of processor to register. * @param processorSupplier * Supplier of processor instances. * @param * Processor type. */ public void register(@Nonnull Class processorClass, @Nonnull Supplier processorSupplier) { processorSuppliers.put(Unchecked.cast(processorClass), Unchecked.cast(processorSupplier)); } /** * @param processorClass * Class of processor to unregister. * @param * Processor type. */ public void unregister(@Nonnull Class processorClass) { processorSuppliers.remove(Unchecked.cast(processorClass)); } /** * Applies all processors to the given workspace. * * @param workspace * Workspace to process. */ public void processWorkspace(@Nonnull Workspace workspace) { for (Supplier processorSupplier : processorSuppliers.values()) { WorkspaceProcessor processor = processorSupplier.get(); logger.trace("Applying workspace processor: {}", processor.name()); processor.processWorkspace(workspace); } } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public WorkspaceProcessingConfig getServiceConfig() { return config; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/WorkspaceProcessor.java ================================================ package software.coley.recaf.services.workspace; import jakarta.annotation.Nonnull; import software.coley.recaf.workspace.model.Workspace; /** * Generic processor for use in {@link WorkspaceProcessingService}. * * @author Matt Coley * @see WorkspaceProcessingService Manages calling implementations of this type. */ public interface WorkspaceProcessor { /** * Called when {@link WorkspaceManager#setCurrent(Workspace)} passes. * * @param workspace * Workspace to process. */ void processWorkspace(@Nonnull Workspace workspace); /** * @return Post processing task name. */ @Nonnull String name(); } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/io/BasicClassPatcher.java ================================================ package software.coley.recaf.services.workspace.io; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassWriter; import org.slf4j.Logger; import software.coley.cafedude.InvalidClassException; import software.coley.cafedude.classfile.ClassFile; import software.coley.cafedude.classfile.attribute.BootstrapMethodsAttribute; import software.coley.cafedude.classfile.behavior.AttributeHolder; import software.coley.cafedude.classfile.constant.ConstDynamic; import software.coley.cafedude.classfile.constant.CpEntry; import software.coley.cafedude.classfile.constant.CpUtf8; import software.coley.cafedude.io.ClassBuilder; import software.coley.cafedude.io.ClassFileReader; import software.coley.cafedude.io.ClassFileWriter; import software.coley.cafedude.io.FallbackInstructionReader; import software.coley.cafedude.transform.IllegalRewritingInstructionsReader; import software.coley.cafedude.transform.IllegalStrippingTransformer; import software.coley.cafedude.transform.Transformer; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.util.Types; import java.io.IOException; import java.util.Collections; import java.util.IdentityHashMap; import java.util.List; import java.util.Map; /** * Basic patcher implementation with CafeDude. * * @author Matt Coley */ @ApplicationScoped public class BasicClassPatcher implements ClassPatcher { private static final Logger logger = Logging.get(BasicClassPatcher.class); @Nonnull @Override public byte[] patch(@Nullable String name, @Nonnull byte[] code) throws IOException { try { // Patch via CafeDude ClassFileReader reader = new ClassFileReaderExt(); ClassFile classFile = reader.read(code); if (name == null) name = classFile.getName(); new BootstrapSpamTransformer(classFile).transform(); new IllegalStrippingTransformerExt(classFile).transform(); return new ClassFileWriter().write(classFile); } catch (InvalidClassException ex) { if (name == null) name = ""; logger.error("CafeDude failed to parse '{}'", name, ex); throw new IOException(ex); } catch (Throwable t) { if (name == null) name = ""; logger.error("CafeDude failed to patch '{}'", name, t); throw new IOException(t); } } /** * Extended class file reader that plugs into {@link IllegalRewritingInstructionsReader}. */ private static class ClassFileReaderExt extends ClassFileReader { private FallbackInstructionReader fallback; @Nonnull @Override public FallbackInstructionReader getFallbackInstructionReader(@Nonnull ClassBuilder builder) { if (fallback == null) fallback = new IllegalRewritingInstructionsReader(builder.getPool(), builder.getVersionMajor()); return fallback; } } /** * When {@link ClassWriter} initializes from an existing {@link ClassReader} it populates a {@code SymbolTable}. * This process will copy the {@link BootstrapMethodsAttribute} which can be filled with entries that have * thousands of arguments. Arguments can refer to other bootstrap methods with their own arguments, * and this can be stacked to any arbitrary level of depth. *
    *
  • If you have 1,000 arguments referring to another BSM, with 1,000 arguments of its own, * you get 1,000,000 arguments total.
  • *
  • If you have 1 argument referring to another BSM which has 2 arguments, * each which refer to another BSM which has 2 arguments, * repeat this 20 times and you get 1,048,576 values.
  • *
* The way ASM copies this data is incredibly slow. */ private static class BootstrapSpamTransformer extends Transformer { private static final int ARG_THRESHOLD = 100; private final Map argCount = new IdentityHashMap<>(); public BootstrapSpamTransformer(@Nonnull ClassFile clazz) { super(clazz); } @Override public void transform() { BootstrapMethodsAttribute attribute = clazz.getAttribute(BootstrapMethodsAttribute.class); if (attribute == null) return; for (BootstrapMethodsAttribute.BootstrapMethod bootstrapMethod : attribute.getBootstrapMethods()) { if (computeTotalArgs(attribute, bootstrapMethod) >= ARG_THRESHOLD) { bootstrapMethod.setArgs(Collections.emptyList()); } } } private int computeTotalArgs(@Nonnull BootstrapMethodsAttribute bsmAttribute, @Nonnull BootstrapMethodsAttribute.BootstrapMethod bsm) { // Get cached count if visited before. Integer cachedValue = argCount.get(bsm); if (cachedValue != null) return cachedValue; // Get direct arg count. List args = bsm.getArgs(); int total = args.size(); // Put the arg count in the map for now, we will update it later. // We just need it here already to handle short-circuiting with the indirect-argument counting. argCount.put(bsm, total); // Only sum indirect-arguments if we're under the threshold. if (total < ARG_THRESHOLD) { for (CpEntry arg : args) { total += countIndirectArgs(bsmAttribute, arg); if (total > ARG_THRESHOLD) break; } } argCount.put(bsm, total); return total; } private int countIndirectArgs(@Nonnull BootstrapMethodsAttribute bsmAttribute, @Nonnull CpEntry arg) { if (arg instanceof ConstDynamic dynamic) { int index = dynamic.getBsmIndex(); var bootstrapMethods = bsmAttribute.getBootstrapMethods(); if (index < 0 || index >= bootstrapMethods.size()) return ARG_THRESHOLD; // Short-circuit loops. // Yield the arg count of the referenced bootstrap method. BootstrapMethodsAttribute.BootstrapMethod bsm = bootstrapMethods.get(dynamic.getBsmIndex()); return computeTotalArgs(bsmAttribute, bsm); } return 0; } } /** * Extended illegal stripping transformer that also drops invalid signatures. */ private static class IllegalStrippingTransformerExt extends IllegalStrippingTransformer { private IllegalStrippingTransformerExt(@Nonnull ClassFile clazz) { super(clazz); } @Override protected boolean matchSignature(@Nonnull CpUtf8 e, @Nonnull AttributeHolder context) { return switch (context.getHolderType()) { case CLASS -> Types.isValidSignature(e.getText(), Types.SignatureContext.CLASS); case FIELD, RECORD_COMPONENT -> Types.isValidSignature(e.getText(), Types.SignatureContext.FIELD); case METHOD -> Types.isValidSignature(e.getText(), Types.SignatureContext.METHOD); case ATTRIBUTE -> false; }; } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/io/BasicInfoImporter.java ================================================ package software.coley.recaf.services.workspace.io; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.objectweb.asm.ClassReader; import org.slf4j.Logger; import software.coley.cafedude.classfile.VersionConstants; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.info.FileInfo; import software.coley.recaf.info.Info; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.builder.ArscFileInfoBuilder; import software.coley.recaf.info.builder.AudioFileInfoBuilder; import software.coley.recaf.info.builder.BinaryXmlFileInfoBuilder; import software.coley.recaf.info.builder.DexFileInfoBuilder; import software.coley.recaf.info.builder.FileInfoBuilder; import software.coley.recaf.info.builder.ImageFileInfoBuilder; import software.coley.recaf.info.builder.JvmClassInfoBuilder; import software.coley.recaf.info.builder.ModulesFileInfoBuilder; import software.coley.recaf.info.builder.NativeLibraryFileInfoBuilder; import software.coley.recaf.info.builder.VideoFileInfoBuilder; import software.coley.recaf.info.builder.ZipFileInfoBuilder; import software.coley.recaf.info.properties.builtin.IllegalClassSuspectProperty; import software.coley.recaf.info.properties.builtin.ZipMarkerProperty; import software.coley.recaf.services.text.TextFormatConfig; import software.coley.recaf.util.ByteHeaderUtil; import software.coley.recaf.util.IOUtil; import software.coley.recaf.util.android.AndroidXmlUtil; import software.coley.recaf.util.io.ByteSource; import java.io.IOException; /** * Basic implementation of the info importer. * * @author Matt Coley */ @ApplicationScoped public class BasicInfoImporter implements InfoImporter { private static final Logger logger = Logging.get(BasicInfoImporter.class); private final ClassPatcher classPatcher; private final InfoImporterConfig config; private final TextFormatConfig formatConfig; @Inject public BasicInfoImporter(@Nonnull InfoImporterConfig config, @Nonnull TextFormatConfig formatConfig, @Nonnull ClassPatcher classPatcher) { this.config = config; this.formatConfig = formatConfig; this.classPatcher = classPatcher; } @Nonnull @Override public Info readInfo(@Nonnull String name, @Nonnull ByteSource source) throws IOException { byte[] data = source.readAll(); // Check for Java classes if (matchesClass(data)) { try { return readClass(name, data); } catch (Throwable t) { // Invalid class. There are a few possibilities here: // - The user has disabled patching in their settings and opened an obfuscated file that kills ASM. // - There is a pattern in the file very similar to a class file, but it is not actually a class file. // - There is an edge case we need to add to CafeDude to allow complete patching. return new FileInfoBuilder<>() .withRawContent(data) .withName(name) .withProperty(IllegalClassSuspectProperty.INSTANCE) .build(); } } // Comparing against known file types. boolean hasZipMarker = ByteHeaderUtil.matchAtAnyOffset(data, ByteHeaderUtil.ZIP); FileInfo info = readAsSpecializedFile(name, data); if (info != null) { if (hasZipMarker) ZipMarkerProperty.set(info); return info; } // Check for ZIP containers (For ZIP/JAR/JMod/WAR) // - While this is more common, some of the known file types may match 'ZIP' with // our 'any-offset' condition we have here. // - We need 'any-offset' to catch all ZIP cases, but it can match some of the file types // above in some conditions, which means we have to check for it last. if (hasZipMarker) { ZipFileInfoBuilder builder = new ZipFileInfoBuilder() .withProperty(new ZipMarkerProperty()) .withRawContent(data) .withName(name); // Record name, handle extension to determine info-type String extension = IOUtil.getExtension(name); if (extension == null) return builder.build(); return switch (extension.toUpperCase()) { case "JAR" -> builder.asJar().build(); case "APK" -> builder.asApk().build(); case "WAR" -> builder.asWar().build(); case "JMOD" -> builder.asJMod().build(); default -> builder.build(); }; } // No special case known for file, treat as generic file // Will be automatically mapped to a text file if the contents are all mappable characters. return new FileInfoBuilder<>() .withRawContent(data) .withName(name) .build(); } /** * @param name * Name of file. * @param data * File content. * * @return The {@link FileInfo} subtype of matched special cases (Media, executables, etc.) * or {@code null} if no special case is matched. */ @Nullable private static FileInfo readAsSpecializedFile(@Nonnull String name, byte[] data) { if (ByteHeaderUtil.match(data, ByteHeaderUtil.DEX)) { return new DexFileInfoBuilder() .withRawContent(data) .withName(name) .build(); } else if (ByteHeaderUtil.match(data, ByteHeaderUtil.MODULES)) { return new ModulesFileInfoBuilder() .withRawContent(data) .withName(name) .build(); } else if (name.toUpperCase().endsWith(".ARSC") && ByteHeaderUtil.match(data, ByteHeaderUtil.ARSC)) { return new ArscFileInfoBuilder() .withRawContent(data) .withName(name) .build(); } else if (name.toUpperCase().endsWith(".XML") && (ByteHeaderUtil.match(data, ByteHeaderUtil.BINARY_XML) || AndroidXmlUtil.hasXmlIndicators(data))) { return new BinaryXmlFileInfoBuilder() .withRawContent(data) .withName(name) .build(); } else if (ByteHeaderUtil.matchAny(data, ByteHeaderUtil.IMAGE_HEADERS)) { return new ImageFileInfoBuilder() .withRawContent(data) .withName(name) .build(); } else if (ByteHeaderUtil.matchAny(data, ByteHeaderUtil.AUDIO_HEADERS)) { return new AudioFileInfoBuilder() .withRawContent(data) .withName(name) .build(); } else if (ByteHeaderUtil.matchAny(data, ByteHeaderUtil.VIDEO_HEADERS)) { return new VideoFileInfoBuilder() .withRawContent(data) .withName(name) .build(); } else if (ByteHeaderUtil.matchAny(data, ByteHeaderUtil.PROGRAM_HEADERS)) { return new NativeLibraryFileInfoBuilder() .withRawContent(data) .withName(name) .build(); } return null; } @Nonnull private Info readClass(@Nonnull String name, @Nonnull byte[] data) throws Throwable { var patchingMode = config.getClassPatchMode(); // If we're skipping validation just parse the class file as-is and don't run validation checks. // Because the validation steps are skipped problems that would otherwise be caught and patched with // higher tier patch modes will occur when opening the class later. Users must accept this responsibility // if they want the boost in workspace load speeds. if (patchingMode == InfoImporterConfig.ClassPatchMode.SKIP_FILTER) // We still do not use 'SKIP_CODE' since we want the info models to have things like variable metadata. return new JvmClassInfoBuilder(data, 0).build(); // If we're always validating, patch the class and try and parse the patched output. // Any ASM parse failures imply patching has failed, and the class will be treated as a file instead (see catch block in calling methods) if (patchingMode == InfoImporterConfig.ClassPatchMode.ALWAYS_FILTER) { byte[] patched = classPatcher.patch(name, data); return new JvmClassInfoBuilder(patched, 0) .skipValidationChecks(false) .build(); } // We're doing a check-then-filter. If ASM reads the class as-is without issue, keep the result. // Otherwise, patch when we encounter parse problems and try again. int readerFlags = patchingMode == InfoImporterConfig.ClassPatchMode.CHECK_ADVANCED_THEN_FILTER ? ClassReader.SKIP_CODE : 0; try { return new JvmClassInfoBuilder() .skipValidationChecks(false) .adaptFrom(data, readerFlags) .build(); } catch (Throwable t) { // Patch if not compatible with ASM byte[] patched = classPatcher.patch(name, data); try { JvmClassInfo patchedClassInfo = new JvmClassInfoBuilder(patched, readerFlags) .skipValidationChecks(false) .build(); logger.debug("CafeDude patched class: {}", name); return patchedClassInfo; } catch (Throwable t1) { logger.error("CafeDude patching output is still non-compliant with ASM for file: {}", formatConfig.filter(name)); throw t1; } } } /** * Check if the byte array is prefixed by the class file magic header. * * @param content * File content. * * @return If the content seems to be a class at a first glance. */ private static boolean matchesClass(byte[] content) { // Null and size check // The smallest valid class possible that is verifiable is 37 bytes AFAIK, but we'll be generous here. if (content == null || content.length <= 16) return false; // We want to make sure the 'magic' is correct. if (!ByteHeaderUtil.match(content, ByteHeaderUtil.CLASS)) return false; // 'dylib' files can also have CAFEBABE as a magic header... Gee, thanks Apple :/ // Because of this we'll employ some more sanity checks. // Version number must be non-zero int version = ((content[6] & 0xFF) << 8) + (content[7] & 0xFF); if (version < VersionConstants.JAVA1) return false; // Must include some constant pool entries. // The smallest number includes: // - utf8 - name of current class // - class - wrapper of prior // - utf8 - name of object class // - class - wrapper of prior` int cpSize = ((content[8] & 0xFF) << 8) + (content[9] & 0xFF); return cpSize >= 4; } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public InfoImporterConfig getServiceConfig() { return config; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/io/BasicResourceImporter.java ================================================ package software.coley.recaf.services.workspace.io; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import org.slf4j.Logger; import software.coley.collections.Unchecked; import software.coley.lljzip.format.model.CentralDirectoryFileHeader; import software.coley.lljzip.format.model.LocalFileHeader; import software.coley.lljzip.format.model.ZipArchive; import software.coley.lljzip.util.ExtraFieldTime; import software.coley.lljzip.util.MemorySegmentUtil; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.info.DexFileInfo; import software.coley.recaf.info.FileInfo; import software.coley.recaf.info.Info; import software.coley.recaf.info.JarFileInfo; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.ModulesFileInfo; import software.coley.recaf.info.ZipFileInfo; import software.coley.recaf.info.builder.FileInfoBuilder; import software.coley.recaf.info.builder.ZipFileInfoBuilder; import software.coley.recaf.info.properties.builtin.InputFilePathProperty; import software.coley.recaf.info.properties.builtin.PathOriginalNameProperty; import software.coley.recaf.info.properties.builtin.PathPrefixProperty; import software.coley.recaf.info.properties.builtin.PathSuffixProperty; import software.coley.recaf.info.properties.builtin.VersionedClassProperty; import software.coley.recaf.info.properties.builtin.ZipAccessTimeProperty; import software.coley.recaf.info.properties.builtin.ZipCommentProperty; import software.coley.recaf.info.properties.builtin.ZipCompressionProperty; import software.coley.recaf.info.properties.builtin.ZipCreationTimeProperty; import software.coley.recaf.info.properties.builtin.ZipEntryIndexProperty; import software.coley.recaf.info.properties.builtin.ZipMarkerProperty; import software.coley.recaf.info.properties.builtin.ZipModificationTimeProperty; import software.coley.recaf.info.properties.builtin.ZipPrefixDataProperty; import software.coley.recaf.services.Service; import software.coley.recaf.util.IOUtil; import software.coley.recaf.util.ModulesIOUtil; import software.coley.recaf.util.ShortcutUtil; import software.coley.recaf.util.StringUtil; import software.coley.recaf.util.android.DexIOUtil; import software.coley.recaf.util.io.ByteSource; import software.coley.recaf.util.io.ByteSources; import software.coley.recaf.util.io.LocalFileHeaderSource; import software.coley.recaf.util.threading.ThreadPoolFactory; import software.coley.recaf.workspace.model.bundle.AndroidClassBundle; import software.coley.recaf.workspace.model.bundle.BasicFileBundle; import software.coley.recaf.workspace.model.bundle.BasicJvmClassBundle; import software.coley.recaf.workspace.model.bundle.BasicVersionedJvmClassBundle; import software.coley.recaf.workspace.model.bundle.VersionedJvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceDirectoryResource; import software.coley.recaf.workspace.model.resource.WorkspaceFileResource; import software.coley.recaf.workspace.model.resource.WorkspaceFileResourceBuilder; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import software.coley.recaf.workspace.model.resource.WorkspaceResourceBuilder; import java.io.File; import java.io.IOException; import java.lang.foreign.MemorySegment; import java.net.URI; import java.net.URL; import java.nio.file.FileVisitOption; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.NavigableMap; import java.util.Objects; import java.util.Set; import java.util.TreeMap; import java.util.concurrent.Callable; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.function.Consumer; /** * Basic implementation of the resource importer. * * @author Matt Coley */ @ApplicationScoped public class BasicResourceImporter implements ResourceImporter, Service { private static final Logger logger = Logging.get(BasicResourceImporter.class); private static final int MAX_WALK_DEPTH = 100; private final InfoImporter infoImporter; private final ResourceImporterConfig config; @Inject public BasicResourceImporter(@Nonnull InfoImporter infoImporter, @Nonnull ResourceImporterConfig config) { this.infoImporter = infoImporter; this.config = config; } /** * General read handling for any single-file resource kind. Delegates to others when needed. * * @param builder * Builder to work with. * @param pathName * Name of input file / content. * @param source * Access to content / data. * * @return Read resource. */ private WorkspaceResource handleSingle(@Nonnull WorkspaceFileResourceBuilder builder, @Nonnull String pathName, @Nonnull ByteSource source) throws IOException { // Read input as raw info in order to determine file-type. PathAndName pathAndName = PathAndName.fromString(pathName); String name = pathAndName.name; Path localPath = pathAndName.path; Info readInfo = infoImporter.readInfo(name, source); // Check if it is a single class. if (readInfo.isClass()) { // If it is a class, we know it MUST be a single JVM class since Android classes do not exist // in single file form. They only come bundled in DEX files. JvmClassInfo readAsJvmClass = readInfo.asClass().asJvmClass(); BasicJvmClassBundle bundle = new BasicJvmClassBundle(); bundle.initialPut(readAsJvmClass); // To satisfy our file-info requirement for the file resource we can create a wrapper file-info // using the JVM class's bytecode. FileInfo fileInfo = new FileInfoBuilder<>() .withName(readAsJvmClass.getName() + ".class") .withRawContent(readAsJvmClass.getBytecode()) .build(); if (localPath != null) InputFilePathProperty.set(fileInfo, localPath); // Associate input path with the read value. return builder.withFileInfo(fileInfo) .withJvmClassBundle(bundle) .build(); } // Associate input path with the read value. if (localPath != null) InputFilePathProperty.set(readInfo, localPath); // Must be some non-class type of file. FileInfo readInfoAsFile = readInfo.asFile(); builder = builder.withFileInfo(readInfoAsFile); // Check for general ZIP container format (ZIP/JAR/WAR/APK/JMod) if (readInfoAsFile.isZipFile()) { ZipFileInfo readInfoAsZip = readInfoAsFile.asZipFile(); return handleZip(builder, readInfoAsZip, source); } else if (ZipMarkerProperty.get(readInfoAsFile)) { // In some cases the file may have been matched as something else (like an executable) // but also count as a ZIP container. Applications that bundle Java applications into native exe files // tend to do this. try { return handleZip(builder, new ZipFileInfoBuilder(readInfoAsFile.toFileBuilder()).build(), source); } catch (Throwable t) { // Some files will just so happen to have a ZIP marker in their bytes but not represent an actual ZIP. // This is fine because by this point we have an info-type to fall back on. logger.debug("Saw ZIP marker in file {} but could not parse as ZIP.", name); } } // Check for DEX file format. if (readInfoAsFile instanceof DexFileInfo) { String dexName = readInfoAsFile.getName(); AndroidClassBundle dexBundle = DexIOUtil.read(readInfoAsFile.getRawContent()); return builder.withAndroidClassBundles(Map.of(dexName, dexBundle)) .build(); } // Must be some edge case type: Modules, or an unknown file type if (readInfoAsFile instanceof ModulesFileInfo) { return handleModules(builder, (ModulesFileInfo) readInfoAsFile); } // Unknown file type BasicFileBundle bundle = new BasicFileBundle(); bundle.initialPut(readInfoAsFile); return builder .withFileBundle(bundle) .build(); } @Nonnull private WorkspaceFileResource handleZip(@Nonnull WorkspaceFileResourceBuilder builder, @Nonnull ZipFileInfo zipInfo, @Nonnull ByteSource source) throws IOException { logger.info("Reading input from ZIP container '{}'", zipInfo.getName()); builder.withFileInfo(zipInfo); BasicJvmClassBundle classes = new BasicJvmClassBundle(); BasicFileBundle files = new BasicFileBundle(); Map androidClassBundles = new ConcurrentHashMap<>(); NavigableMap versionedJvmClassBundles = Collections.synchronizedNavigableMap(new TreeMap<>()); Map embeddedResources = new ConcurrentHashMap<>(); // Read ZIP boolean isAndroid = zipInfo.getName().toLowerCase().endsWith(".apk"); ZipArchive archive = config.mapping().apply(source.readAll()); // Sanity check, if there's data at the head of the file AND its otherwise empty its probably junk. MemorySegment prefixData = archive.getPrefixData(); if (prefixData != null && archive.getEnd() != null && archive.getParts().size() == 1) { // We'll throw as the caller should catch this case and handle it based on their needs. throw new IOException("Content matched ZIP header but had no file entries"); } // Record prefix data to attribute held by the zip file info. if (prefixData != null) { ZipPrefixDataProperty.set(zipInfo, MemorySegmentUtil.toByteArray(prefixData)); } // Build model from the contained files in the ZIP int maxZipDepth = config.getMaxEmbeddedZipDepth().getValue(); try (ExecutorService service = config.doParallelize().getValue() ? ThreadPoolFactory.newFixedThreadPool("zip-import") : ThreadPoolFactory.newSingleThreadExecutor("zip-import")) { List> tasks = new ArrayList<>(); List localFiles = archive.getLocalFiles(); for (int i = 0; i < localFiles.size(); i++) { int entryIndex = i; LocalFileHeader header = localFiles.get(i); tasks.add(() -> { LocalFileHeaderSource headerSource = new LocalFileHeaderSource(header, isAndroid); String entryName = header.getFileNameAsString(); // Skip directories. There is no such thing as a 'directory' entry in ZIP files. // The only thing we can say is that if it ends with a '/' and has no data associated with it, // then it is probably a directory. if (entryName.endsWith("/") && Unchecked.getOr(headerSource::isEmpty, false)) return null; // Read the value of the entry to figure out how to handle adding it to the resource builder. Info info; try { info = infoImporter.readInfo(entryName, headerSource); ZipEntryIndexProperty.set(info, entryIndex); } catch (IOException ex) { logger.error("IO error reading ZIP entry '{}' - skipping", entryName, ex); return null; } // Record common entry attributes ZipCompressionProperty.set(info, header.getCompressionMethod()); ExtraFieldTime.TimeWrapper extraTimes = ExtraFieldTime.read(header); CentralDirectoryFileHeader centralHeader = header.getLinkedDirectoryFileHeader(); if (centralHeader != null) { if (centralHeader.getFileCommentLength() > 0) ZipCommentProperty.set(info, centralHeader.getFileCommentAsString()); if (extraTimes == null) extraTimes = ExtraFieldTime.read(centralHeader); } if (extraTimes != null) { ZipCreationTimeProperty.set(info, extraTimes.getCreationMs()); ZipModificationTimeProperty.set(info, extraTimes.getModifyMs()); ZipAccessTimeProperty.set(info, extraTimes.getAccessMs()); } // Skipping ZIP bombs if (info.isFile() && info.asFile().isZipFile()) { ZipFileInfo zipFile = info.asFile().asZipFile(); if (Arrays.equals(zipFile.getRawContent(), zipInfo.getRawContent())) { logger.warn("Skip self-extracting ZIP bomb: {}", entryName); return null; } else if (Arrays.stream(Thread.currentThread().getStackTrace()) .filter(trace -> trace.getMethodName().equals("handleZip")) .count() > maxZipDepth) { logger.warn("Skip extracting embedded ZIP after {} levels: {}", maxZipDepth, entryName); return null; } } // Add the info to the appropriate bundle addInfo(classes, files, androidClassBundles, versionedJvmClassBundles, embeddedResources, headerSource, entryName, info); return null; }); } try { service.invokeAll(tasks); } catch (InterruptedException ex) { throw new IOException("Zip import interrupted", ex); } } return builder .withJvmClassBundle(classes) .withAndroidClassBundles(androidClassBundles) .withVersionedJvmClassBundles(versionedJvmClassBundles) .withFileBundle(files) .withEmbeddedResources(embeddedResources) .withFileInfo(zipInfo) .build(); } @Nonnull private WorkspaceDirectoryResource handleDirectory(@Nonnull WorkspaceResourceBuilder builder, @Nonnull Path directoryPath) throws IOException { logger.info("Reading input from directory '{}'", directoryPath); BasicJvmClassBundle classes = new BasicJvmClassBundle(); BasicFileBundle files = new BasicFileBundle(); Map androidClassBundles = new ConcurrentHashMap<>(); NavigableMap versionedJvmClassBundles = Collections.synchronizedNavigableMap(new TreeMap<>()); Map embeddedResources = new ConcurrentHashMap<>(); // Walk the directory try (ExecutorService service = config.doParallelize().getValue() ? ThreadPoolFactory.newFixedThreadPool("directory-import") : ThreadPoolFactory.newSingleThreadExecutor("directory-import")) { List> tasks = new ArrayList<>(); Files.walkFileTree(directoryPath, Set.of(FileVisitOption.FOLLOW_LINKS), MAX_WALK_DEPTH, new SymlinkFollowingVisitor(directoryPath, MAX_WALK_DEPTH, path -> { tasks.add(() -> { try { Path file = ShortcutUtil.follow(path, MAX_WALK_DEPTH); // Read info from file (relative to the root directory) // - NIO insists both paths be of the same kind (relative vs absolute) so make both absolute. ByteSource source = ByteSources.forPath(file); String fileName = directoryPath.toAbsolutePath().relativize(file.toAbsolutePath()).toString(); if (File.separator.equals("\\")) fileName = fileName.replace('\\', '/'); Info info = infoImporter.readInfo(fileName, source); // Add the info to the appropriate bundle addInfo(classes, files, androidClassBundles, versionedJvmClassBundles, embeddedResources, source, fileName, info); } catch (IOException ex) { logger.error("IO error walking directory entry '{}' - skipping", path, ex); } return null; }); })); try { service.invokeAll(tasks); } catch (InterruptedException ex) { throw new IOException("Directory import interrupted", ex); } } return builder .withJvmClassBundle(classes) .withAndroidClassBundles(androidClassBundles) .withVersionedJvmClassBundles(versionedJvmClassBundles) .withFileBundle(files) .withEmbeddedResources(embeddedResources) .withDirectoryPath(directoryPath) .build(); } private void addInfo(@Nonnull BasicJvmClassBundle classes, @Nonnull BasicFileBundle files, @Nonnull Map androidClassBundles, @Nonnull NavigableMap versionedJvmClassBundles, @Nonnull Map embeddedResources, @Nonnull ByteSource infoSource, @Nonnull String pathName, @Nonnull Info info) { if (info.isClass()) { addClassInfo(classes, files, versionedJvmClassBundles, pathName, info); } else if (info.isFile()) { addFileInfo(files, androidClassBundles, embeddedResources, infoSource, pathName, info); } else { throw new IllegalStateException("Unknown info type: " + info); } } private void addClassInfo(@Nonnull BasicJvmClassBundle classes, @Nonnull BasicFileBundle files, @Nonnull NavigableMap versionedJvmClassBundles, @Nonnull String pathName, @Nonnull Info info) { // Must be a JVM class since Android classes do not exist in single-file form. JvmClassInfo classInfo = info.asClass().asJvmClass(); String className = classInfo.getName(); // JVM edge case allows trailing '/' for class entries in JARs. // We're going to normalize that away. if (pathName.endsWith(".class/")) { pathName = pathName.replace(".class/", ".class"); } // Record the class name, including path suffix/prefix. // If the name is totally different, record the original path name. int index = pathName.indexOf(className); if (index >= 0) { // Class name is within the entry name. // Record the prefix before the class name, and suffix after it (extension). if (index > 0) { String prefix = pathName.substring(0, index); PathPrefixProperty.set(classInfo, prefix); } int suffixIndex = index + className.length(); if (suffixIndex < pathName.length()) { String suffix = pathName.substring(suffixIndex); PathSuffixProperty.set(classInfo, suffix); } } else { // Class name doesn't match entry name. PathOriginalNameProperty.set(classInfo, pathName); } // First we must handle edge cases. Up first, we'll look at multi-release jar prefixes. if (pathName.startsWith(JarFileInfo.MULTI_RELEASE_PREFIX) && !className.startsWith(JarFileInfo.MULTI_RELEASE_PREFIX)) { String versionName = ""; try { // Extract version from '/version/' pattern int startOffset = JarFileInfo.MULTI_RELEASE_PREFIX.length(); int slashIndex = pathName.indexOf('/', startOffset); if (slashIndex < 0) throw new NumberFormatException("Version name is null"); versionName = pathName.substring(startOffset, slashIndex); // Only add if the names match int classStart = slashIndex + 1; int classEnd = pathName.length() - ".class".length(); if (classEnd > classStart) { String classPath = pathName.substring(classStart, classEnd); if (!classPath.equals(className)) throw new IllegalArgumentException("Class in multi-release directory" + " does not match it's declared class name: " + classPath); } else { throw new IllegalArgumentException("Class in multi-release directory " + "does not end in '.class'"); } // Put it into the correct versioned class bundle. int version = Integer.parseInt(versionName); BasicJvmClassBundle bundle = (BasicJvmClassBundle) versionedJvmClassBundles .computeIfAbsent(version, BasicVersionedJvmClassBundle::new); // Handle duplicate classes. // noinspection all synchronized (bundle) { JvmClassInfo existingClass = bundle.get(className); if (existingClass != null) { deduplicateClass(existingClass, classInfo, bundle, files); } else { VersionedClassProperty.set(classInfo, version); bundle.initialPut(classInfo); } } } catch (NumberFormatException ex) { // Version is invalid, record it as a file instead. logger.warn("Class entry seemed to be for multi-release jar, " + "but version is non-numeric value: " + versionName); // Override the prior value. // The JVM always selects the last option if there are duplicates. files.initialPut(new FileInfoBuilder<>() .withName(pathName) .withRawContent(classInfo.getBytecode()) .build()); } catch (IllegalArgumentException ex) { // Class name doesn't match what is declared locally in the versioned folder. logger.warn("Class entry seemed to be for multi-release jar, " + "but the name doesn't align with the declared type: " + pathName); // Override the prior value. // The JVM always selects the last option if there are duplicates. files.initialPut(new FileInfoBuilder<>() .withName(pathName) .withRawContent(classInfo.getBytecode()) .build()); } return; } // Handle duplicate classes. // noinspection all synchronized (classes) { JvmClassInfo existingClass = classes.get(className); if (existingClass != null) { deduplicateClass(existingClass, classInfo, classes, files); } else { classes.initialPut(classInfo); } } } private void addFileInfo(@Nonnull BasicFileBundle files, @Nonnull Map androidClassBundles, @Nonnull Map embeddedResources, @Nonnull ByteSource infoSource, @Nonnull String pathName, @Nonnull Info info) { FileInfo fileInfo = info.asFile(); // Check for special file cases (Currently just DEX) if (fileInfo instanceof DexFileInfo) { try { AndroidClassBundle dexBundle = DexIOUtil.read(infoSource); androidClassBundles.put(pathName, dexBundle); } catch (Throwable t) { logger.error("Failed to read embedded DEX '{}'", pathName, t); files.initialPut(fileInfo); } return; } // Check for container file cases (Any ZIP type, JAR/WAR/etc) if (fileInfo.isZipFile()) { try { WorkspaceFileResourceBuilder embeddedResourceBuilder = new WorkspaceFileResourceBuilder() .withFileInfo(fileInfo); WorkspaceFileResource embeddedResource = handleZip(embeddedResourceBuilder, fileInfo.asZipFile(), infoSource); embeddedResources.put(pathName, embeddedResource); } catch (Throwable t) { logger.error("Failed to read embedded ZIP '{}'", pathName, t); files.initialPut(fileInfo); } return; } // Check for other edge case types containing embedded content. if (fileInfo instanceof ModulesFileInfo) { try { WorkspaceResourceBuilder embeddedResourceBuilder = new WorkspaceResourceBuilder() .withFileInfo(fileInfo); WorkspaceFileResource embeddedResource = (WorkspaceFileResource) handleModules(embeddedResourceBuilder, (ModulesFileInfo) fileInfo); embeddedResources.put(pathName, embeddedResource); } catch (Throwable t) { logger.error("Failed to read embedded ZIP '{}'", pathName, t); files.initialPut(fileInfo); } return; } // Warn if there are duplicate file entries. // Same cases for why this may occur are described above when handling classes. // The JVM will always use the last item for duplicate entries anyways. synchronized (files) { FileInfo existingFile = files.get(pathName); if (existingFile != null) { int existingIndex = ZipEntryIndexProperty.getOr(existingFile, -1); int newIndex = ZipEntryIndexProperty.getOr(fileInfo, -1); if (newIndex < existingIndex) return; logger.warn("Multiple duplicate entries for file '{}', dropping older entry", pathName); } // Store in bundle. files.initialPut(fileInfo); } } /** * Should ONLY be called if there is an existing duplicate/conflict in the given JVM class bundle. * * @param existingClass * Prior class entry in the class bundle. * @param currentClass * New entry to de-duplicate. * @param classes * Target class bundle. * @param files * Target file bundle for fallback item placement. */ private void deduplicateClass(@Nonnull JvmClassInfo existingClass, @Nonnull JvmClassInfo currentClass, @Nonnull BasicJvmClassBundle classes, @Nonnull BasicFileBundle files) { String className = currentClass.getName(); String existingPrefix = PathPrefixProperty.get(existingClass); String existingSuffix = PathSuffixProperty.get(existingClass); String existingOriginal = PathOriginalNameProperty.get(existingClass); String currentPrefix = PathPrefixProperty.get(currentClass); String currentSuffix = PathSuffixProperty.get(currentClass); String currentOriginal = PathOriginalNameProperty.get(currentClass); // The target names to use should we want to store the items as files String existingName = existingOriginal != null ? existingOriginal : (existingPrefix != null ? existingPrefix : "") + className + (existingSuffix != null ? existingSuffix : ""); String currentName = currentOriginal != null ? currentOriginal : (currentPrefix != null ? currentPrefix : "") + className + (currentSuffix != null ? currentSuffix : ""); // Check for literal duplicate ZIP entries. if (existingName.equals(currentName)) { // The new name is an exact match, but occurs later in the file. // Since the JVM prefers the last entry of a set of duplicates we will drop the prior value. logger.warn("Dropping prior class duplicate, matched exact file path: {}", className); classes.initialPut(currentClass); return; } // Ok, so the path names aren't the same. // We'll want to normalize the paths and compare them. Whichever is best fit to be the JVM class will be kept // in the classes bundle. The worse fit goes to the files bundle. If we aren't sure then the newest entry // lands in the JVM bundle. // Normalize prefix/suffix if (Objects.equals(existingPrefix, currentPrefix)) { existingPrefix = null; currentPrefix = null; } if (Objects.equals(existingSuffix, currentSuffix)) { existingSuffix = null; currentSuffix = null; } // Names to use for comparison purposes String cmpExistingName = existingOriginal != null ? existingOriginal : (existingPrefix != null ? existingPrefix : "") + className + (existingSuffix != null ? existingSuffix : ""); String cmpCurrentName = currentOriginal != null ? currentOriginal : (currentPrefix != null ? currentPrefix : "") + className + (currentSuffix != null ? currentSuffix : ""); // Try and get class names via the file paths and determine which is the best fit to the real class name. String commonPrefix = StringUtil.getCommonPrefix(cmpExistingName, cmpCurrentName); if (commonPrefix.startsWith(JarFileInfo.MULTI_RELEASE_PREFIX)) { // Class names start at the '//' int i = commonPrefix.indexOf('/', JarFileInfo.MULTI_RELEASE_PREFIX.length()) + 1; cmpExistingName = cmpExistingName.substring(i); cmpCurrentName = cmpCurrentName.substring(i); } else if (!commonPrefix.isEmpty()) { // Class names should start at the common prefix minus the intersection of the class name cmpExistingName = commonPrefix + cmpExistingName.substring(commonPrefix.length()); cmpCurrentName = commonPrefix + cmpCurrentName.substring(commonPrefix.length()); } // Best fit checking if (cmpExistingName.equals(className + ".class")) { // The existing class entry name IS the class name. Thus, the other (current) one does not match. // We will add the current one as a file instead, and keep the prior as a class. logger.warn("Duplicate class '{}' found. The prior entry better aligns to class name so the new one " + "will be tracked as a file instead: {}", className, currentName); files.initialPut(new FileInfoBuilder<>() .withName(currentName) .withRawContent(currentClass.getBytecode()) .build()); } else if (cmpCurrentName.equals(className + ".class")) { // The current class entry name IS the class name. Thus, the other (prior) one does not match. // We will add the prior one as a file, and record this new one as a class logger.warn("Duplicate class '{}' found. The new entry better aligns to class name so the prior one " + "will be tracked as a file instead: {}", className, existingName); VersionedClassProperty.remove(existingClass); files.initialPut(new FileInfoBuilder<>() .withName(existingName) .withRawContent(existingClass.getBytecode()) .build()); classes.initialPut(currentClass); } else { // Neither of them really follow the class name accurately. We'll just record the last one as the JVM class // because that more accurately follows JVM behavior. logger.warn("Duplicate class '{}' found. Neither entry match their class names," + " tracking the newer item as the JVM class and retargeting the old item as a file: {}", className, existingName); VersionedClassProperty.remove(existingClass); files.initialPut(new FileInfoBuilder<>() .withName(existingName) .withRawContent(existingClass.getBytecode()) .build()); classes.initialPut(currentClass); } } private WorkspaceResource handleModules(WorkspaceResourceBuilder builder, ModulesFileInfo moduleInfo) throws IOException { BasicJvmClassBundle classes = new BasicJvmClassBundle(); BasicFileBundle files = new BasicFileBundle(); // The file-info should have the absolute path set as a property. // We have to use a path because unless we implement our own module reader, the internal API // only provides reader access via a path item. Path pathToModuleFile = InputFilePathProperty.get(moduleInfo); if (pathToModuleFile == null) throw new IOException("Content of modules can only be read from path, which has not been set for this model"); ModulesIOUtil.stream(pathToModuleFile) .forEach(entry -> { // Follows the pattern: // // - entry extracts these values ModulesIOUtil.Entry moduleEntry = entry.getElement(); ByteSource moduleFileSource = entry.getByteSource(); Info info; try { info = infoImporter.readInfo(moduleEntry.getFileName(), moduleFileSource); } catch (IOException ex) { logger.error("IO error reading modules entry '{}' - skipping", moduleEntry.getOriginalPath()); return; } // Add to appropriate bundle. // Modules file only has two expected kinds of content, classes and generic files. if (info.isClass()) { // Modules file only contains JVM classes classes.initialPut(info.asClass().asJvmClass()); } else { // Anything else should be a general file files.initialPut(info.asFile()); } // Record the original prefix '//' for the input PathPrefixProperty.set(info, "/" + moduleEntry.getModuleName() + "/"); }); return builder .withJvmClassBundle(classes) .withFileBundle(files) .build(); } @Nonnull @Override public WorkspaceResource importResource(@Nonnull ByteSource source) throws IOException { return handleSingle(new WorkspaceFileResourceBuilder(), "unknown.dat", source); } @Nonnull @Override public WorkspaceResource importResource(@Nonnull Path path) throws IOException { // Load name/data from path, parse into resource. String absolutePath = StringUtil.pathToAbsoluteString(path); if (Files.isDirectory(path)) { return handleDirectory(new WorkspaceFileResourceBuilder(), path); } else { ByteSource byteSource = ByteSources.forPath(path); return handleSingle(new WorkspaceFileResourceBuilder(), absolutePath, byteSource); } } @Nonnull @Override public WorkspaceResource importResource(@Nonnull URL url) throws IOException { // Extract name from URL String path; if (url.getProtocol().equals("file")) { path = url.getFile(); if (path.isEmpty()) path = url.toString(); if (path.charAt(0) == '/') path = path.substring(1); } else { path = url.toString(); } // Load content, parse into resource. byte[] bytes = IOUtil.toByteArray(url.openStream()); ByteSource byteSource = ByteSources.wrap(bytes); return handleSingle(new WorkspaceFileResourceBuilder(), path, byteSource); } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public ResourceImporterConfig getServiceConfig() { return config; } /** * Using {@link FileVisitOption#FOLLOW_LINKS} doesn't actually work for directories that are symlinks. * This visitor implementation manually handles directory symlinks by resolving them to their real paths * and walking them separately. */ private static class SymlinkFollowingVisitor extends SimpleFileVisitor { private final Path rootDir; private final int maxDepthFromRoot; private final Consumer filePathConsumer; public SymlinkFollowingVisitor(@Nonnull Path rootDir, int maxDepthFromRoot, Consumer filePathConsumer) { this.rootDir = rootDir; this.maxDepthFromRoot = maxDepthFromRoot; this.filePathConsumer = filePathConsumer; } @Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { if (!dir.equals(dir.toRealPath())) { // Java NIO is stupid and will not follow symbolic links of directories. int depth = rootDir.relativize(dir).getNameCount(); int newDepth = maxDepthFromRoot - depth; if (newDepth > 0) { Path realDir = dir.toRealPath(); Files.walkFileTree(realDir, Set.of(FileVisitOption.FOLLOW_LINKS), newDepth, new SymlinkFollowingVisitor(realDir, newDepth, filePathConsumer)); return FileVisitResult.SKIP_SUBTREE; } else { return FileVisitResult.SKIP_SUBTREE; } } return FileVisitResult.CONTINUE; } @Override public FileVisitResult visitFile(@Nonnull Path path, @Nonnull BasicFileAttributes attrs) { filePathConsumer.accept(path); return FileVisitResult.CONTINUE; } } private record PathAndName(@Nullable Path path, @Nonnull String name) { @Nonnull private static PathAndName fromString(@Nonnull String pathName) { if (pathName.contains("://")) { // Absolute URI paths if (pathName.startsWith("file://")) { return fromUriString(pathName); } else { // Probably something like "https://foo.com/bar.zip" // Try normalizing a simple name out of it if possible. while (pathName.endsWith("/")) pathName = pathName.substring(0, pathName.length() - 1); String name = pathName.substring(pathName.lastIndexOf('/') + 1); if (!name.matches("\\w+")) name = "remote"; return new PathAndName(null, name); } } else if (pathName.startsWith("file:./")) { // Relative URI paths return fromUriString(pathName); } else { // Probably local file paths Path localPath; try { // Try and resolve a file path to the give path-name. // In some cases the input name is a remote resource not covered by the block above, // so we don't really care if it fails. That just means it is a remote resource of some kind. localPath = Paths.get(pathName); } catch (Throwable t) { localPath = null; } return new PathAndName(localPath, pathName.substring(pathName.lastIndexOf('/') + 1)); } } @Nonnull private static PathAndName fromUriString(@Nonnull String pathName) { String name; Path localPath; name = pathName.substring(pathName.lastIndexOf('/') + 1); try { // Try and resolve a file path to the give path-name. // It should be an absolute path. localPath = Paths.get(URI.create(pathName)); } catch (Throwable t) { localPath = null; } return new PathAndName(localPath, name); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/io/ByteArrayWorkspaceExportConsumer.java ================================================ package software.coley.recaf.services.workspace.io; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import java.io.IOException; /** * Export consumer to write to a {@code byte[]}. Only supports {@link WorkspaceOutputType#FILE}. * * @author Matt Coley */ public class ByteArrayWorkspaceExportConsumer implements WorkspaceExportConsumer { private byte[] output; @Override public void write(@Nonnull byte[] bytes) throws IOException { if (output == null) output = bytes; else { int existingContentLength = output.length; int newContentLength = bytes.length; int mergedLength = existingContentLength + newContentLength; if (mergedLength < 0) // Overflow check throw new IllegalStateException("Content too large to write to a single byte[]"); byte[] newOutput = new byte[mergedLength]; System.arraycopy(output, 0, newOutput, 0, existingContentLength); System.arraycopy(bytes, 0, newOutput, existingContentLength, newContentLength); output = newOutput; } } @Override public void writeRelative(@Nonnull String relative, @Nonnull byte[] bytes) { throw new IllegalStateException("Directory export not supported in byte-array export consumer"); } @Override public void commit() throws IOException { // no-op } /** * @return Output content. May be {@code null} if nothing was in the workspace to write. */ @Nullable public byte[] getOutput() { return output; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/io/ClassPatcher.java ================================================ package software.coley.recaf.services.workspace.io; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import java.io.IOException; /** * Service outline for patching intentionally malformed Java bytecode to be compliant with ASM. * * @author Matt Coley */ public interface ClassPatcher { /** * @param name * Name given by user for logging purposes. * @param code * Input bytecode. * * @return Output filtered bytecode. * * @throws IOException * When an exception patching the bytecode occurs. */ @Nonnull byte[] patch(@Nullable String name, @Nonnull byte[] code) throws IOException; } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/io/InfoImporter.java ================================================ package software.coley.recaf.services.workspace.io; import jakarta.annotation.Nonnull; import software.coley.recaf.info.Info; import software.coley.recaf.services.Service; import software.coley.recaf.util.io.ByteSource; import java.io.IOException; /** * Service outline for creating various {@link Info} types from a basic name, {@link ByteSource} pair. * * @author Matt Coley */ public interface InfoImporter extends Service { String SERVICE_ID = "info-importer"; /** * @param name * Name to pass for {@link Info#getName()} if it cannot be inferred from the content source. * @param source * Source of content to read data from. * * @return Info instance. * * @throws IOException * When the content cannot be read. */ @Nonnull Info readInfo(@Nonnull String name, @Nonnull ByteSource source) throws IOException; } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/io/InfoImporterConfig.java ================================================ package software.coley.recaf.services.workspace.io; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.observables.ObservableObject; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.BasicConfigValue; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link InfoImporter} * * @author Matt Coley */ @ApplicationScoped public class InfoImporterConfig extends BasicConfigContainer implements ServiceConfig { private final ObservableObject classPatchMode = new ObservableObject<>(ClassPatchMode.CHECK_BASIC_THEN_FILTER); @Inject public InfoImporterConfig() { super(ConfigGroups.SERVICE_IO, InfoImporter.SERVICE_ID + CONFIG_SUFFIX); addValue(new BasicConfigValue<>("class-patch-mode", ClassPatchMode.class, classPatchMode)); } /** * You better know what you're doing if you choose a lower tier value on this. * The default is {@link ClassPatchMode#CHECK_BASIC_THEN_FILTER} because otherwise * there will be errors when invalid classes are found. However, in some cases * it may be beneficial to use {@link ClassPatchMode#ALWAYS_FILTER}. * Only use {@link ClassPatchMode#SKIP_FILTER} when looking at unobfuscated classes. * * @return Class patch validation mode. */ @Nonnull public ClassPatchMode getClassPatchMode() { return classPatchMode.getValue(); } /** * Level of class pre-processing to take when importing {@link ClassInfo} types. */ public enum ClassPatchMode { /** * Always pre-process classes. */ ALWAYS_FILTER, /** * Check thoroughly for problems in class files before pre-processing them. */ CHECK_ADVANCED_THEN_FILTER, /** * Check quickly for problems in class files before pre-processing them. */ CHECK_BASIC_THEN_FILTER, /** * Do not pre-process classes. */ SKIP_FILTER } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/io/PathWorkspaceExportConsumer.java ================================================ package software.coley.recaf.services.workspace.io; import jakarta.annotation.Nonnull; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; /** * Export consumer to write to a given {@link Path}, either as a single file or as the root of a directory of items. * * @author Matt Coley */ public class PathWorkspaceExportConsumer implements WorkspaceExportConsumer { private final Path path; private boolean firstSingleWrite = true; /** * @param path * Path to write to. */ public PathWorkspaceExportConsumer(@Nonnull Path path) { this.path = path; } @Override public void write(@Nonnull byte[] bytes) throws IOException { if (firstSingleWrite) { Files.write(path, bytes); firstSingleWrite = false; } else { Files.write(path, bytes, StandardOpenOption.APPEND); } } @Override public void writeRelative(@Nonnull String relativePath, @Nonnull byte[] bytes) throws IOException { Path destination = path.resolve(relativePath); Path parent = destination.getParent(); if (!Files.isDirectory(parent)) Files.createDirectories(parent); Files.write(destination, bytes); } @Override public void commit() throws IOException { // No-need to do anything } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/io/ResourceImporter.java ================================================ package software.coley.recaf.services.workspace.io; import jakarta.annotation.Nonnull; import software.coley.recaf.util.io.ByteSource; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.io.File; import java.io.IOException; import java.net.URI; import java.net.URL; import java.nio.file.Path; /** * Service outline for supporting creation of {@link WorkspaceResource} instances. * * @author Matt Coley */ public interface ResourceImporter { String SERVICE_ID = "resource-importer"; /** * @param source * Some generic content source. * * @return Workspace resource representing the content. * * @throws IOException * When the content cannot be read from. */ @Nonnull WorkspaceResource importResource(@Nonnull ByteSource source) throws IOException; /** * @param file * File/directory to import from. * * @return Workspace resource representing the file/directory. * * @throws IOException * When the content at the file path cannot be read from. */ @Nonnull default WorkspaceResource importResource(@Nonnull File file) throws IOException { return importResource(file.toPath()); } /** * @param path * File/directory path to import from. * * @return Workspace resource representing the file/directory. * * @throws IOException * When the content at the file path cannot be read from. */ @Nonnull WorkspaceResource importResource(@Nonnull Path path) throws IOException; /** * @param url * URL to content to import from. * * @return Workspace resource representing the remote content. * * @throws IOException * When content from the URL cannot be accessed. */ @Nonnull WorkspaceResource importResource(@Nonnull URL url) throws IOException; /** * @param uri * URI to content to import from. * * @return Workspace resource representing the remote content. * * @throws IOException * When reading from the URI fails either due to a malformed URI, * or the content being inaccessible. */ @Nonnull default WorkspaceResource importResource(@Nonnull URI uri) throws IOException { return importResource(uri.toURL()); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/io/ResourceImporterConfig.java ================================================ package software.coley.recaf.services.workspace.io; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.collections.func.UncheckedFunction; import software.coley.lljzip.ZipIO; import software.coley.lljzip.format.ZipPatterns; import software.coley.lljzip.format.model.CentralDirectoryFileHeader; import software.coley.lljzip.format.model.LocalFileHeader; import software.coley.lljzip.format.model.ZipArchive; import software.coley.lljzip.format.read.ForwardScanZipReader; import software.coley.lljzip.format.read.JvmZipReader; import software.coley.lljzip.format.read.NaiveLocalFileZipReader; import software.coley.lljzip.format.read.SimpleZipPartAllocator; import software.coley.lljzip.format.read.ZipPartAllocator; import software.coley.lljzip.util.MemorySegmentUtil; import software.coley.lljzip.util.data.MemorySegmentData; import software.coley.lljzip.util.data.StringData; import software.coley.observables.ObservableBoolean; import software.coley.observables.ObservableInteger; import software.coley.observables.ObservableObject; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.BasicConfigValue; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; import java.lang.foreign.MemorySegment; import static software.coley.lljzip.util.MemorySegmentUtil.readLongSlice; /** * Config for {@link ResourceImporter}. * * @author Matt Coley */ @ApplicationScoped public class ResourceImporterConfig extends BasicConfigContainer implements ServiceConfig { private final ObservableObject zipStrategy = new ObservableObject<>(ZipStrategy.JVM); private final ObservableBoolean skipRevisitedCenToLocalLinks = new ObservableBoolean(true); private final ObservableBoolean allowBasicJvmBaseOffsetZeroCheck = new ObservableBoolean(true); private final ObservableBoolean ignoreFileLengths = new ObservableBoolean(false); private final ObservableBoolean adoptStandardCenFileNames = new ObservableBoolean(false); private final ObservableInteger maxEmbeddedZipDepth = new ObservableInteger(3); private final ObservableBoolean parallelize = new ObservableBoolean(true); @Inject public ResourceImporterConfig() { super(ConfigGroups.SERVICE_IO, ResourceImporter.SERVICE_ID + CONFIG_SUFFIX); addValue(new BasicConfigValue<>("zip-strategy", ZipStrategy.class, zipStrategy)); addValue(new BasicConfigValue<>("skip-revisited-cen-to-local-links", boolean.class, skipRevisitedCenToLocalLinks)); addValue(new BasicConfigValue<>("allow-basic-base-offset-zero-check", boolean.class, allowBasicJvmBaseOffsetZeroCheck)); addValue(new BasicConfigValue<>("ignore-file-lengths", boolean.class, ignoreFileLengths)); addValue(new BasicConfigValue<>("adapt-standard-cen-file-names", boolean.class, adoptStandardCenFileNames)); addValue(new BasicConfigValue<>("max-embedded-zip-depth", int.class, maxEmbeddedZipDepth)); addValue(new BasicConfigValue<>("parallelize", boolean.class, parallelize)); } /** * @return ZIP strategy. */ @Nonnull public ObservableObject getZipStrategy() { return zipStrategy; } /** * When the {@link #getZipStrategy() ZIP strategy} is {@link ZipStrategy#JVM} this allows toggling * skipping "duplicate" entries where multiple {@link CentralDirectoryFileHeader} can point to the * same offset ({@link LocalFileHeader}). Skipping is {@code true} by default. * * @return {@code true} when skipping N-to-1 mapping of * {@link CentralDirectoryFileHeader} to {@link LocalFileHeader} for {@link ZipStrategy#JVM}. */ @Nonnull public ObservableBoolean getSkipRevisitedCenToLocalLinks() { return skipRevisitedCenToLocalLinks; } /** * When the {@link #getZipStrategy() ZIP strategy} is {@link ZipStrategy#JVM} this allows toggling * how the JVM base offset of the zip file is calculated. Normally the start of a ZIP file is calculated * based off the logic in {@code ZipFile.Source#findEND()} which looks like: *
{@code
	 *  // ENDSIG matched, however the size of file comment in it does
	 *  // not match the real size. One "common" cause for this problem
	 *  // is some "extra" bytes are padded at the end of the zipfile.
	 *  // Let's do some extra verification, we don't care about the
	 *  // performance in this situation.
	 *  byte[] sbuf = new byte[4];
	 *  long cenpos = end.endpos - end.cenlen;
	 *  long locpos = cenpos - end.cenoff;
	 * }
* In some edge cases this results in {@code locpos} being {@code > 0} even when the file has no prefix/padding. * * @return {@code true} when defaulting to check for zero being the base JVM zip offset instead of the lookup * based on the code in {@code ZipFile.Source#findEND()}. */ @Nonnull public ObservableBoolean getAllowBasicJvmBaseOffsetZeroCheck() { return allowBasicJvmBaseOffsetZeroCheck; } /** * Post-processes naively read zip archives to expand file data to the next zip structure boundary. * This is necessary in some cases where an archive composed entirely of local file headers has bogus * file length values (such as zero) when file data appears after the file name in the zip structure. * * @return {@code true} to ignore the reported file lengths when using {@link ZipStrategy#NAIVE}. */ @Nonnull public ObservableBoolean getIgnoreFileLengths() { return ignoreFileLengths; } /** * @return Maximum level of embedded resources to populate. * Any further embedded contents will be treated as arbitrary binary files. */ @Nonnull public ObservableInteger getMaxEmbeddedZipDepth() { return maxEmbeddedZipDepth; } /** * @return {@code true} to enable parallelization of import logic. */ @Nonnull public ObservableBoolean doParallelize() { return parallelize; } /** * @return Mapping of input bytes to a ZIP archive model. */ @Nonnull public UncheckedFunction mapping() { ZipStrategy strategy = zipStrategy.getValue(); if (strategy == ZipStrategy.JVM) return newJvmMapping(); if (strategy == ZipStrategy.STANDARD) return newStandardMapping(); return newNaiveMapping(); } @Nonnull private UncheckedFunction newNaiveMapping() { return input -> ZipIO.read(input, new NaiveLocalFileZipReader(newPartAllocator())); } @Nonnull private UncheckedFunction newStandardMapping() { return input -> ZipIO.read(input, new ForwardScanZipReader(newPartAllocator()) { @Override public void postProcessLocalFileHeader(@Nonnull LocalFileHeader file) { if (adoptStandardCenFileNames.getValue()) { CentralDirectoryFileHeader directoryFileHeader = file.getLinkedDirectoryFileHeader(); if (directoryFileHeader != null) file.setFileName(StringData.of(directoryFileHeader.getFileNameAsString())); } } }); } @Nonnull private UncheckedFunction newJvmMapping() { return input -> ZipIO.read(input, new JvmZipReader(skipRevisitedCenToLocalLinks.getValue(), allowBasicJvmBaseOffsetZeroCheck.getValue())); } /** * @return Part allocator for Naive/Standard strategies. */ @Nonnull private ZipPartAllocator newPartAllocator() { if (ignoreFileLengths.getValue()) { return new SimpleZipPartAllocator() { @Nonnull @Override public LocalFileHeader newLocalFileHeader() { return new LocalFileHeader() { @Nonnull @Override protected MemorySegmentData readFileData(@Nonnull MemorySegment data, long headerOffset) { long localOffset = MIN_FIXED_SIZE + getFileNameLength() + getExtraFieldLength(); long nextStart = MemorySegmentUtil.indexOfQuad(data, headerOffset + localOffset, ZipPatterns.LOCAL_FILE_HEADER_QUAD); long fileDataLength = nextStart > headerOffset ? nextStart - (headerOffset + localOffset) : data.byteSize() - (headerOffset + localOffset); return MemorySegmentData.of(readLongSlice(data, headerOffset, localOffset, fileDataLength)); } }; } }; } return new SimpleZipPartAllocator(); } /** * Mirrors strategies available in {@link ZipIO}. */ public enum ZipStrategy { JVM, STANDARD, NAIVE } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/io/WorkspaceCompressType.java ================================================ package software.coley.recaf.services.workspace.io; import software.coley.recaf.info.Info; import software.coley.recaf.info.properties.builtin.ZipCompressionProperty; /** * Compression option for ZIP/JAR outputs in {@link WorkspaceExportOptions}. * * @author Matt Coley */ public enum WorkspaceCompressType { /** * Match the original compression of a {@link Info} item by checking {@link ZipCompressionProperty}. * When unknown, defaults to enabling compression. */ MATCH_ORIGINAL, /** * Compress items only when if it will yield more compact data. * Some smaller files do not compress well due to the overhead cost of the compression. */ SMART, /** * Compress all items in the output. */ ALWAYS, /** * Do not compress any items in the output. */ NEVER, } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/io/WorkspaceExportConsumer.java ================================================ package software.coley.recaf.services.workspace.io; import jakarta.annotation.Nonnull; import java.io.IOException; /** * Outline of IO writing for {@link WorkspaceExporter} output. * * @author Matt Coley */ public interface WorkspaceExportConsumer { /** * Called when writing content to a single given location based on the implementation. * This may be called multiple times before {@link #commit()} is invoked. * * @param bytes * Bytes to write/append to the output. * * @throws IOException * When the content cannot be written to. */ void write(@Nonnull byte[] bytes) throws IOException; /** * Called when writing content to a relative location based on the implementation. * This may be called multiple times for a given relative path before {@link #commit()} is invoked. * * @param relative * Relative path of content. * @param bytes * Bytes to write/append to the given relative path. * * @throws IOException * When the content cannot be written to. */ void writeRelative(@Nonnull String relative, @Nonnull byte[] bytes) throws IOException; /** * Called when the export process is completed. * * @throws IOException * When the content couldn't be committed. */ void commit() throws IOException; } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/io/WorkspaceExportOptions.java ================================================ package software.coley.recaf.services.workspace.io; import jakarta.annotation.Nonnull; import software.coley.collections.Unchecked; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.FileInfo; import software.coley.recaf.info.Info; import software.coley.recaf.info.JarFileInfo; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.ZipFileInfo; import software.coley.recaf.info.properties.builtin.PathOriginalNameProperty; import software.coley.recaf.info.properties.builtin.PathPrefixProperty; import software.coley.recaf.info.properties.builtin.PathSuffixProperty; import software.coley.recaf.info.properties.builtin.ZipAccessTimeProperty; import software.coley.recaf.info.properties.builtin.ZipCommentProperty; import software.coley.recaf.info.properties.builtin.ZipCompressionProperty; import software.coley.recaf.info.properties.builtin.ZipCreationTimeProperty; import software.coley.recaf.info.properties.builtin.ZipModificationTimeProperty; import software.coley.recaf.info.properties.builtin.ZipPrefixDataProperty; import software.coley.recaf.util.ZipCreationUtils; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.AndroidClassBundle; import software.coley.recaf.workspace.model.bundle.VersionedJvmClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceFileResource; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.TreeMap; import java.util.zip.DeflaterOutputStream; import static software.coley.lljzip.format.compression.ZipCompressions.DEFLATED; import static software.coley.lljzip.format.compression.ZipCompressions.STORED; /** * Options for configuring / preparing a {@link WorkspaceExporter}. * * @author Matt Coley */ public class WorkspaceExportOptions { private final WorkspaceCompressType compressType; private final WorkspaceOutputType outputType; private final WorkspaceExportConsumer consumer; private boolean bundleSupporting; private boolean createZipDirEntries; /** * @param outputType * Type of output for contents. * @param consumer * Consumer to write to. */ public WorkspaceExportOptions(@Nonnull WorkspaceOutputType outputType, @Nonnull WorkspaceExportConsumer consumer) { this(WorkspaceCompressType.MATCH_ORIGINAL, outputType, consumer); } /** * @param compressType * Compression option for contents exported. * @param outputType * Type of output for contents. * @param consumer * Consumer to write to. */ public WorkspaceExportOptions(@Nonnull WorkspaceCompressType compressType, @Nonnull WorkspaceOutputType outputType, @Nonnull WorkspaceExportConsumer consumer) { this.compressType = compressType; this.outputType = outputType; this.consumer = consumer; } /** * @param bundleSupporting * {@code true} to bundle all {@link Workspace#getSupportingResources()} into the output. */ public void setBundleSupporting(boolean bundleSupporting) { this.bundleSupporting = bundleSupporting; } /** * @param createZipDirEntries * {@code true} to create directory entries in the output ZIP. * Does nothing when output type is a directory. */ public void setCreateZipDirEntries(boolean createZipDirEntries) { this.createZipDirEntries = createZipDirEntries; } /** * @return New exporter from current options. */ @Nonnull public WorkspaceExporter create() { return new WorkspaceExporterImpl(); } /** * Basic implementation of {@link WorkspaceExporter} that pulls from the options defined here. */ private class WorkspaceExporterImpl implements WorkspaceExporter { private final Map contents = new TreeMap<>(); private final Map compression = new HashMap<>(); private final Map comments = new HashMap<>(); private final Map modifyTimes = new HashMap<>(); private final Map createTimes = new HashMap<>(); private final Map accessTimes = new HashMap<>(); private byte[] prefix; @Override public void export(@Nonnull Workspace workspace) throws IOException { populate(workspace); switch (outputType) { case FILE: // Test if we're supposed to just write the file as-is instead of bundling it in an archive. // - Must only have one thing to write // - Workspace input must be a single non-archive file if (contents.size() == 1 && workspace.getPrimaryResource() instanceof WorkspaceFileResource primaryFileResource && !(primaryFileResource.getFileInfo() instanceof ZipFileInfo)) { byte[] data = contents.values().iterator().next(); if (prefix != null) consumer.write(prefix); consumer.write(data); return; } // Otherwise, lets make an archive. ZipCreationUtils.ZipBuilder zipBuilder = ZipCreationUtils.builder(); if (createZipDirEntries) zipBuilder = zipBuilder.createDirectories(); // Final copy for lambda, write all contents to ZIP buffer. ZipCreationUtils.ZipBuilder finalZipBuilder = zipBuilder; contents.forEach((name, content) -> { // Cannot mirror exact compression type, so we'll just do binary "is this compressed or nah?" boolean compress = compression.getOrDefault(name, STORED) > STORED; // Other properties String comment = comments.getOrDefault(name, null); long modifyTime = modifyTimes.getOrDefault(name, -1L); long createTime = createTimes.getOrDefault(name, -1L); long accessTime = accessTimes.getOrDefault(name, -1L); // Adding the entry finalZipBuilder.add(name, content, compress, comment, createTime, modifyTime, accessTime); }); // Write buffer to path if (prefix != null) { consumer.write(prefix); consumer.write(zipBuilder.bytes()); } else { consumer.write(zipBuilder.bytes()); } consumer.commit(); break; case DIRECTORY: for (Map.Entry entry : contents.entrySet()) { // Write everything relative to the path String relativePath = entry.getKey(); byte[] content = entry.getValue(); consumer.writeRelative(relativePath, content); } consumer.commit(); break; } } /** * @param workspace * Workspace to pull data from. */ private void populate(@Nonnull Workspace workspace) { // If shading libs, they go first so the primary content will be the authoritative copy for // any duplicate paths held by both resources. if (bundleSupporting) { for (WorkspaceResource supportingResource : workspace.getSupportingResources()) { mapInto(contents, supportingResource); } } WorkspaceResource primary = workspace.getPrimaryResource(); mapInto(contents, primary); // If the resource had prefix data, get it here so that we can write it back later. if (primary instanceof WorkspaceFileResource resource) prefix = ZipPrefixDataProperty.get(resource.getFileInfo()); } /** * Takes the contents of the given resource and puts them into the map. * * @param map * Map to collect values into. * @param resource * Resource to pull values from. */ private void mapInto(@Nonnull Map map, @Nonnull WorkspaceResource resource) { // Place classes into map resource.jvmClassBundleStream().forEach(bundle -> { for (JvmClassInfo classInfo : bundle) { String key; String originalName = PathOriginalNameProperty.get(classInfo); if (originalName == null) { String pathPrefix = PathPrefixProperty.get(classInfo); String pathSuffix = Objects.requireNonNullElse(PathSuffixProperty.get(classInfo), ".class"); key = classInfo.getName() + pathSuffix; if (pathPrefix != null) key = pathPrefix + key; } else { key = originalName; } map.put(key, classInfo.getBytecode()); updateProperties(key, classInfo); } }); // Place versioned files into map for (Map.Entry entry : resource.getVersionedJvmClassBundles().entrySet()) { String versionPath = JarFileInfo.MULTI_RELEASE_PREFIX + entry.getKey() + "/"; for (Map.Entry classEntry : entry.getValue().entrySet()) { String key = versionPath + classEntry.getKey() + ".class"; JvmClassInfo value = classEntry.getValue(); map.put(key, value.getBytecode()); updateProperties(key, value); } } // Rebuild Android DEX files and place into map for (Map.Entry entry : resource.getAndroidClassBundles().entrySet()) { // TODO: Need to write back DEX files } // Place files into map for (FileInfo fileInfo : resource.getFileBundle()) { map.put(fileInfo.getName(), fileInfo.getRawContent()); updateProperties(fileInfo.getName(), fileInfo); } // Recreate embedded resources as ZIP files with the original file paths for (Map.Entry entry : resource.getEmbeddedResources().entrySet()) { String embeddedFilePath = entry.getKey(); WorkspaceFileResource embeddedResource = entry.getValue(); Map embeddedMap = new TreeMap<>(); mapInto(embeddedMap, embeddedResource); byte[] embeddedBytes = Unchecked.get(() -> ZipCreationUtils.createZip(embeddedMap)); map.put(embeddedFilePath, embeddedBytes); FileInfo embeddedFile = embeddedResource.getFileInfo(); updateProperties(embeddedFilePath, embeddedFile); } } /** * @param name * Map key. * @param info * Info to pull properties from. */ private void updateProperties(@Nonnull String name, @Nonnull Info info) { compression.put(name, getCompression(info)); Long createTime = ZipCreationTimeProperty.get(info); if (createTime != null) createTimes.put(name, createTime); Long modifyTime = ZipModificationTimeProperty.get(info); if (modifyTime != null) modifyTimes.put(name, modifyTime); Long accessTime = ZipAccessTimeProperty.get(info); if (accessTime != null) accessTimes.put(name, accessTime); String comment = ZipCommentProperty.get(info); if (comment != null) comments.put(name, comment); } /** * @param info * Info to get compression for. * * @return Compression type for into value. */ private int getCompression(@Nonnull Info info) { switch (compressType) { case ALWAYS: return DEFLATED; case NEVER: return STORED; case SMART: // Get content from info byte[] content = null; if (info.isFile()) content = info.asFile().getRawContent(); else if (info.isClass()) { ClassInfo classInfo = info.asClass(); if (classInfo.isJvmClass()) content = classInfo.asJvmClass().getBytecode(); } // Validate if (content == null) throw new IllegalStateException("Unhandled info type, cannot get byte[]: " + info.getClass().getName()); // Check if deflate would be more optimal. InputStream in = new ByteArrayInputStream(content); ByteArrayOutputStream out = new ByteArrayOutputStream(); try (DeflaterOutputStream deflate = new DeflaterOutputStream(out)) { byte[] buffer = new byte[2048]; int len; while ((len = in.read(buffer)) > 0) { deflate.write(buffer, 0, len); } deflate.finish(); int inputSize = content.length; int compressedSize = out.size(); if (compressedSize < inputSize) return DEFLATED; } catch (IOException ignored) { // Cannot compress } return STORED; case MATCH_ORIGINAL: default: return ZipCompressionProperty.getOr(info, DEFLATED); } } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/io/WorkspaceExporter.java ================================================ package software.coley.recaf.services.workspace.io; import jakarta.annotation.Nonnull; import software.coley.recaf.workspace.model.Workspace; import java.io.IOException; /** * Outline for supporting exporting of {@link Workspace} back into files. * * @author Matt Coley * @see WorkspaceExportOptions */ public interface WorkspaceExporter { /** * The actions of exporting are configured by {@link WorkspaceExportOptions}. * * @param workspace * The workspace to export. * * @throws IOException * When exporting failed for any IO related reason. */ void export(@Nonnull Workspace workspace) throws IOException; } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/io/WorkspaceOutputType.java ================================================ package software.coley.recaf.services.workspace.io; import software.coley.recaf.workspace.model.resource.WorkspaceFileResource; /** * Output option between single files and directories in {@link WorkspaceExportOptions}. * * @author Matt Coley */ public enum WorkspaceOutputType { /** * Output to a single file. The type of which is determined by the primary resource's * {@link WorkspaceFileResource#getFileInfo()} if available. Otherwise, defaults to ZIP/JAR. *

* Delegates to {@link WorkspaceExportConsumer#write(byte[])} */ FILE, /** * Output to a directory. *

* Delegates to {@link WorkspaceExportConsumer#writeRelative(String, byte[])} */ DIRECTORY } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/PatchApplier.java ================================================ package software.coley.recaf.services.workspace.patch; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import me.darknet.assembler.compile.JavaClassRepresentation; import me.darknet.assembler.error.Error; import org.slf4j.Logger; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.info.Info; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.TextFileInfo; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.path.FilePathNode; import software.coley.recaf.path.PathNode; import software.coley.recaf.services.Service; import software.coley.recaf.services.assembler.AssemblerPipelineManager; import software.coley.recaf.services.assembler.JvmAssemblerPipeline; import software.coley.recaf.services.workspace.patch.model.JvmAssemblerPatch; import software.coley.recaf.services.workspace.patch.model.RemovePath; import software.coley.recaf.services.workspace.patch.model.TextFilePatch; import software.coley.recaf.services.workspace.patch.model.WorkspacePatch; import software.coley.recaf.util.StringDiff; import software.coley.recaf.workspace.model.bundle.Bundle; import software.coley.recaf.workspace.model.bundle.FileBundle; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; /** * Service to apply {@link WorkspacePatch}s. * * @author Matt Coley */ @ApplicationScoped public class PatchApplier implements Service { public static final String SERVICE_ID = "resource-patch-applier"; private static final Logger logger = Logging.get(PatchApplier.class); private final AssemblerPipelineManager assemblerPipelineManager; private final ResourcePatchApplierConfig config; @Inject public PatchApplier(@Nonnull AssemblerPipelineManager assemblerPipelineManager, @Nonnull ResourcePatchApplierConfig config) { this.assemblerPipelineManager = assemblerPipelineManager; this.config = config; } /** * Applies the given patch to the workspace it's associated with. * * @param patch * Patch to apply. * @param feedback * Optional feedback for receiving errors. * When any error is observed the patching process is abandoned. * * @return {@code true} When the patch was successful. * {@code false} when the patch was abandoned. */ public boolean apply(@Nonnull WorkspacePatch patch, @Nullable PatchFeedback feedback) { List tasks = new ArrayList<>(); ErrorDelegate errorConsumerDelegate = new ErrorDelegate(feedback == null ? null : feedback::onAssemblerErrorsObserved); for (RemovePath removal : patch.removals()) { PathNode path = removal.path(); Info toRemove = path.getValueOfType(Info.class); Bundle containingBundle = path.getValueOfType(Bundle.class); if (containingBundle == null) { if (feedback != null) feedback.onIncompletePathObserved(path); return false; } if (toRemove == null) { if (feedback != null) feedback.onIncompletePathObserved(path); return false; } String entryName = toRemove.getName(); tasks.add(() -> { if (containingBundle.remove(entryName) == null) logger.warn("Could not apply removal for path '{}' - not found in the workspace", entryName); }); } JvmAssemblerPipeline jvmAssemblerPipeline = assemblerPipelineManager.newJvmAssemblerPipeline(patch.workspace()); for (JvmAssemblerPatch jvmAssemblerPatch : patch.jvmAssemblerPatches()) { // Skip if any errors have been seen. if (errorConsumerDelegate.hasSeenErrors()) return false; ClassPathNode path = jvmAssemblerPatch.path().withCurrentWorkspaceContent(); JvmClassInfo jvmClass = path.getValue().asJvmClass(); JvmClassBundle jvmBundle = path.getValueOfType(JvmClassBundle.class); if (jvmBundle == null) { if (feedback != null) feedback.onIncompletePathObserved(path); return false; } // Apply patch List diffs = jvmAssemblerPatch.assemblerDiffs(); jvmAssemblerPipeline.disassemble(path).ifOk(disassemble -> { // Apply diffs to disassembled class. String patchedAssembly = StringDiff.Diff.apply(disassemble, diffs); // Reassemble the class and update the workspace. // And parse / assemble step failure jvmAssemblerPipeline.tokenize(patchedAssembly, "") .flatMap(jvmAssemblerPipeline::roughParse) .flatMap(jvmAssemblerPipeline::concreteParse) .flatMap(concreteAst -> jvmAssemblerPipeline.assemble(concreteAst, path)) .ifOk(patchResult -> { JavaClassRepresentation representation = patchResult.representation(); if (representation != null) { tasks.add(() -> { JvmClassInfo patchedClass = jvmClass.toJvmClassBuilder() .adaptFrom(representation.classFile()) .build(); jvmBundle.put(patchedClass); }); } }).ifErr(errorConsumerDelegate::errors); }).ifErr(errors -> { // Disassemble failure if (feedback != null) feedback.onAssemblerErrorsObserved(errors); }); } for (TextFilePatch filePatch : patch.textFilePatches()) { // Skip if any errors have been seen. if (errorConsumerDelegate.hasSeenErrors()) return false; FilePathNode path = filePatch.path().withCurrentWorkspaceContent(); TextFileInfo textFile = path.getValue().asTextFile(); FileBundle fileBundle = path.getValueOfType(FileBundle.class); if (fileBundle == null) { if (feedback != null) feedback.onIncompletePathObserved(path); return false; } String patchedText = StringDiff.Diff.apply(textFile.getText(), filePatch.textDiffs()); tasks.add(() -> { TextFileInfo patchedTextFile = textFile.toTextBuilder() .withRawContent(patchedText.getBytes(textFile.getCharset())) .build(); fileBundle.put(patchedTextFile); }); } // If no errors have been seen apply all patches. if (!errorConsumerDelegate.hasSeenErrors()) { for (Runnable task : tasks) { try { task.run(); } catch (Throwable t) { // Most likely caused by listeners and not the patch itself. // Log the error and continue. logger.error("Error applying patch task", t); } } } return true; } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public ResourcePatchApplierConfig getServiceConfig() { return config; } private static class ErrorDelegate { private final Consumer> errorConsumer; private boolean seenErrors; private ErrorDelegate(@Nullable Consumer> errorConsumer) {this.errorConsumer = errorConsumer;} public void errors(List errors) { seenErrors = true; if (errorConsumer != null) errorConsumer.accept(errors); } public boolean hasSeenErrors() { return seenErrors; } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/PatchFeedback.java ================================================ package software.coley.recaf.services.workspace.patch; import jakarta.annotation.Nonnull; import me.darknet.assembler.error.Error; import software.coley.recaf.path.PathNode; import software.coley.recaf.services.workspace.patch.model.WorkspacePatch; import java.util.List; /** * System for receiving error notifications when attempting to * {@link PatchApplier#apply(WorkspacePatch, PatchFeedback) apply patches}. * * @author Matt Coley */ public interface PatchFeedback { /** * Called when a {@link WorkspacePatch#jvmAssemblerPatches()} could not be applied. * * @param errors * Assembler errors observed. */ default void onAssemblerErrorsObserved(@Nonnull List errors) {} /** * Called when a required path in a {@link WorkspacePatch} did not contain all necessary components. * * @param path * Incomplete path. */ default void onIncompletePathObserved(@Nonnull PathNode path) {} } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/PatchGenerationException.java ================================================ package software.coley.recaf.services.workspace.patch; import jakarta.annotation.Nonnull; /** * Exception to outline various patch generation and serialization problems. * * @author Matt Coley */ public class PatchGenerationException extends Exception { public PatchGenerationException(@Nonnull Throwable cause, @Nonnull String message) { super(message, cause); } public PatchGenerationException(@Nonnull Throwable cause) { super(cause); } public PatchGenerationException(@Nonnull String message) { super(message); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/PatchProvider.java ================================================ package software.coley.recaf.services.workspace.patch; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import me.darknet.assembler.error.Result; import org.slf4j.Logger; import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.info.*; import software.coley.recaf.path.*; import software.coley.recaf.services.Service; import software.coley.recaf.services.assembler.AssemblerPipelineManager; import software.coley.recaf.services.assembler.JvmAssemblerPipeline; import software.coley.recaf.services.workspace.patch.model.JvmAssemblerPatch; import software.coley.recaf.services.workspace.patch.model.RemovePath; import software.coley.recaf.services.workspace.patch.model.TextFilePatch; import software.coley.recaf.services.workspace.patch.model.WorkspacePatch; import software.coley.recaf.util.StringDiff; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.Bundle; import software.coley.recaf.workspace.model.bundle.ClassBundle; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; /** * Service to provide and handle serialization of {@link WorkspacePatch}s. * * @author Matt Coley */ @ApplicationScoped public class PatchProvider implements Service { public static final String SERVICE_ID = "resource-patch-provider"; private static final Logger logger = Logging.get(PatchProvider.class); private final AssemblerPipelineManager assemblerPipelineManager; private final ResourcePatchProviderConfig config; @Inject public PatchProvider(@Nonnull AssemblerPipelineManager assemblerPipelineManager, @Nonnull ResourcePatchProviderConfig config) { this.assemblerPipelineManager = assemblerPipelineManager; this.config = config; } /** * Maps a workspace patch into JSON. * * @param patch * Patch to serialize. * * @return JSON string representation of the patch. */ @Nonnull public String serializePatch(@Nonnull WorkspacePatch patch) { return PatchSerialization.serialize(patch); } /** * Maps a JSON file into a workspace patch. * * @param workspace * Workspace to apply the patch to. * @param patchPath * Path to the JSON file outlining patch contents. * * @return A workspace patch instance. * * @throws IOException * When the JSON file couldn't be read. * @throws PatchGenerationException * When the JSON file couldn't be parsed, or its contents could not be found in the workspace. */ @Nonnull public WorkspacePatch deserializePatch(@Nonnull Workspace workspace, @Nonnull Path patchPath) throws IOException, PatchGenerationException { return deserializePatch(workspace, Files.readString(patchPath)); } /** * Maps a JSON file into a workspace patch. * * @param workspace * Workspace to apply the patch to. * @param patchContents * JSON outlining patch contents. * * @return A workspace patch instance. * * @throws PatchGenerationException * When the JSON file couldn't be parsed, or its contents could not be found in the workspace. */ @Nonnull public WorkspacePatch deserializePatch(@Nonnull Workspace workspace, @Nonnull String patchContents) throws PatchGenerationException { return PatchSerialization.deserialize(workspace, patchContents); } /** * Creates a patch which models all changes in the given workspace. * * @param workspace * Workspace to generate a patch for. * * @return Patch modeling all changes made in the workspace. * * @throws PatchGenerationException * When the patch couldn't be made for any reason. */ @Nonnull public WorkspacePatch createPatch(@Nonnull Workspace workspace) throws PatchGenerationException { List removals = new ArrayList<>(); List jvmAssemblerPatches = new ArrayList<>(); List textFilePatches = new ArrayList<>(); PatchConsumer classConsumer = (classPath, initial, current) -> { DirectoryPathNode parent = Objects.requireNonNull(classPath.getParent()); ClassPathNode initialPath = parent.child(initial); ClassPathNode currentPath = parent.child(current); JvmAssemblerPipeline assembler = assemblerPipelineManager.newJvmAssemblerPipeline(workspace); Result initialDisassembleRes = assembler.disassemble(initialPath); Result currentDisassembleRes = assembler.disassemble(currentPath); if (!initialDisassembleRes.hasValue()) throw new PatchGenerationException("Failed to disassemble initial state of '" + initial.getName() + "'"); if (initialDisassembleRes.hasErr()) throw new PatchGenerationException("Initial state of '" + initial.getName() + "' has assembler errors"); if (!currentDisassembleRes.hasValue()) throw new PatchGenerationException("Failed to disassemble current state of '" + initial.getName() + "'"); if (currentDisassembleRes.hasErr()) throw new PatchGenerationException("Current state of '" + initial.getName() + "' has assembler errors"); String initialDisassemble = initialDisassembleRes.get(); String currentDisassemble = currentDisassembleRes.get(); List assemblerDiffs = StringDiff.diff(initialDisassemble, currentDisassemble); if (!assemblerDiffs.isEmpty()) jvmAssemblerPatches.add(new JvmAssemblerPatch(initialPath, assemblerDiffs)); }; PatchConsumer fileConsumer = (filePath, initial, current) -> { if (initial.isTextFile() && current.isTextFile()) { String initialText = initial.asTextFile().getText(); String currentText = current.asTextFile().getText(); List textDiffs = StringDiff.diff(initialText, currentText); if (!textDiffs.isEmpty()) textFilePatches.add(new TextFilePatch(filePath, textDiffs)); } else { // TODO: Support binary patches of non-text files logger.debug("Skipping file diff for '{}' as it is not a text file", initial.getName()); } }; try { WorkspaceResource resource = workspace.getPrimaryResource(); ResourcePathNode resourcePath = PathNodes.resourcePath(workspace, resource); resource.bundleStream().forEach(b -> { BundlePathNode bundlePath = resourcePath.child(b); Set removedKeys = b.getRemovedKeys(); for (String key : removedKeys) { if (b instanceof ClassBundle) { ClassInfo stub = new StubClassInfo(key); ClassPathNode stubPath = bundlePath.child(stub.getPackageName()).child(stub); removals.add(new RemovePath(stubPath)); } else { FileInfo stub = new StubFileInfo(key); FilePathNode stubPath = bundlePath.child(stub.getDirectoryName()).child(stub); removals.add(new RemovePath(stubPath)); } } }); visitDirtyItems(workspace, resource, resource.getJvmClassBundle(), classConsumer); for (var entry : resource.getVersionedJvmClassBundles().entrySet()) { visitDirtyItems(workspace, resource, entry.getValue(), classConsumer); } visitDirtyItems(workspace, resource, resource.getFileBundle(), fileConsumer); } catch (Throwable t) { throw new PatchGenerationException(t); } return new WorkspacePatch(workspace, Collections.unmodifiableList(removals), Collections.unmodifiableList(jvmAssemblerPatches), Collections.unmodifiableList(textFilePatches)); } @SuppressWarnings({"unchecked", "DataFlowIssue"}) private > void visitDirtyItems(@Nonnull Workspace workspace, @Nonnull WorkspaceResource resource, @Nonnull Bundle bundle, @Nonnull PatchConsumer consumer) throws Throwable { BundlePathNode bundlePath = PathNodes.bundlePath(workspace, resource, bundle); Set dirtyKeys = bundle.getDirtyKeys(); for (String dirtyKey : dirtyKeys) { Stack history = bundle.getHistory(dirtyKey); I current = history.peek(); I oldest = history.elementAt(0); int lastDirSeparator = dirtyKey.lastIndexOf('/'); String directoryName = lastDirSeparator >= 0 ? dirtyKey.substring(0, lastDirSeparator) : null; DirectoryPathNode directoryPath = bundlePath.child(directoryName); if (current instanceof ClassInfo currentClass) { ClassPathNode classPath = directoryPath.child(currentClass); consumer.accept((P) classPath, oldest, current); } else if (current instanceof FileInfo currentFile) { FilePathNode filePath = directoryPath.child(currentFile); consumer.accept((P) filePath, oldest, current); } } } @Nonnull @Override public String getServiceId() { return SERVICE_ID; } @Nonnull @Override public ResourcePatchProviderConfig getServiceConfig() { return config; } @FunctionalInterface private interface PatchConsumer

, I extends Info> { void accept(P path, I initial, I current) throws Throwable; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/PatchSerialization.java ================================================ package software.coley.recaf.services.workspace.patch; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; import jakarta.annotation.Nonnull; import software.coley.recaf.info.Info; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.path.FilePathNode; import software.coley.recaf.services.workspace.patch.model.JvmAssemblerPatch; import software.coley.recaf.services.workspace.patch.model.RemovePath; import software.coley.recaf.services.workspace.patch.model.TextFilePatch; import software.coley.recaf.services.workspace.patch.model.WorkspacePatch; import software.coley.recaf.util.StringDiff; import software.coley.recaf.workspace.model.Workspace; import java.io.IOException; import java.io.StringReader; import java.io.StringWriter; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * Patch serialization helper for {@link PatchProvider}. * * @author Matt Coley */ public class PatchSerialization { private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); private static final String KEY_REMOVALS = "removals"; private static final String KEY_CLASS_JVM_ASM_DIFFS = "class-jvm-asm-diffs"; private static final String KEY_FILE_TEXT_DIFFS = "file-text-diffs"; private static final String KEY_NAME = "name"; private static final String KEY_DIFFS = "diffs"; private static final String KEY_TYPE = "type"; private static final String KEY_START_A = "start-a"; private static final String KEY_END_A = "end-a"; private static final String KEY_TEXT_A = "text-a"; private static final String KEY_START_B = "start-b"; private static final String KEY_END_B = "end-b"; private static final String KEY_TEXT_B = "text-b"; private static final String TYPE_CLASS = "class"; private static final String TYPE_FILE = "file"; private PatchSerialization() {} /** * Maps a workspace patch into JSON. * * @param patch * Patch to serialize. * * @return JSON string representation of the patch. */ @Nonnull public static String serialize(@Nonnull WorkspacePatch patch) { StringWriter out = new StringWriter(); try { JsonWriter jw = GSON.newJsonWriter(out); List removals = patch.removals(); List jvmAssemblerPatches = patch.jvmAssemblerPatches(); List textFilePatches = patch.textFilePatches(); serializeRemovals(jw, removals); serializeJvmAsmPatches(jvmAssemblerPatches, jw); serializeTextPatches(textFilePatches, jw); } catch (Exception ex) { throw new IllegalStateException("Failed to create json writer for patch", ex); } return out.toString(); } private static void serializeRemovals(@Nonnull JsonWriter jw, @Nonnull List removals) throws IOException { jw.beginObject(); if (!removals.isEmpty()) { jw.name(KEY_REMOVALS).beginArray(); for (RemovePath removal : removals) { Info info = removal.path().getValueOfType(Info.class); if (info == null) continue; String name = info.getName(); jw.beginObject(); if (info.isClass()) { jw.name(KEY_TYPE).value(TYPE_CLASS); jw.name(KEY_NAME).value(name); } else if (info.isFile()) { jw.name(KEY_TYPE).value(TYPE_FILE); jw.name(KEY_NAME).value(name); } jw.endObject(); } jw.endArray(); } } private static void serializeJvmAsmPatches(@Nonnull List jvmAssemblerPatches, @Nonnull JsonWriter jw) throws IOException { if (!jvmAssemblerPatches.isEmpty()) { jw.name(KEY_CLASS_JVM_ASM_DIFFS).beginArray(); for (JvmAssemblerPatch classPatch : jvmAssemblerPatches) { String className = classPatch.path().getValue().getName(); jw.beginObject(); jw.name(KEY_NAME).value(className); jw.name(KEY_DIFFS).beginArray(); for (StringDiff.Diff assemblerDiff : classPatch.assemblerDiffs()) serializeStringDiff(jw, assemblerDiff); jw.endArray().endObject(); } jw.endArray(); } } private static void serializeTextPatches(@Nonnull List textFilePatches, @Nonnull JsonWriter jw) throws IOException { if (!textFilePatches.isEmpty()) { jw.name(KEY_FILE_TEXT_DIFFS).beginArray(); for (TextFilePatch textPatch : textFilePatches) { String fileName = textPatch.path().getValue().getName(); jw.beginObject(); jw.name(KEY_NAME).value(fileName); jw.name(KEY_DIFFS).beginArray(); for (StringDiff.Diff assemblerDiff : textPatch.textDiffs()) serializeStringDiff(jw, assemblerDiff); jw.endArray().endObject(); } jw.endArray(); } jw.endObject(); } private static void serializeStringDiff(@Nonnull JsonWriter jw, @Nonnull StringDiff.Diff diff) throws IOException { jw.beginObject(); jw.name(KEY_TYPE).value(diff.type().name()); jw.name(KEY_START_A).value(diff.startA()); jw.name(KEY_END_A).value(diff.endA()); jw.name(KEY_TEXT_A).value(diff.textA()); jw.name(KEY_START_B).value(diff.startB()); jw.name(KEY_END_B).value(diff.endB()); jw.name(KEY_TEXT_B).value(diff.textB()); jw.endObject(); } /** * Maps a JSON file into a workspace patch. * * @param workspace * Workspace to apply the patch to. * @param patchContents * JSON outlining patch contents. * * @return A workspace patch instance. * * @throws PatchGenerationException * When the JSON file couldn't be parsed, or its contents could not be found in the workspace. */ @Nonnull public static WorkspacePatch deserialize(@Nonnull Workspace workspace, @Nonnull String patchContents) throws PatchGenerationException { List removals = Collections.emptyList(); List jvmAssemblerPatches = Collections.emptyList(); List textFilePatches = Collections.emptyList(); if (patchContents.isBlank() || patchContents.charAt(0) != '{' || patchContents.charAt(patchContents.length() - 1) != '}') return new WorkspacePatch(workspace, removals, jvmAssemblerPatches, textFilePatches); try { JsonReader jr = GSON.newJsonReader(new StringReader(patchContents)); jr.beginObject(); while (jr.hasNext()) { String name = jr.nextName(); switch (name) { case KEY_CLASS_JVM_ASM_DIFFS -> jvmAssemblerPatches = deserializeClassJvmAsmDiffs(workspace, jr); case KEY_FILE_TEXT_DIFFS -> textFilePatches = deserializeFileTextDiffs(workspace, jr); case KEY_REMOVALS -> removals = deserializeRemovals(workspace, jr); } } jr.endObject(); return new WorkspacePatch(workspace, removals, jvmAssemblerPatches, textFilePatches); } catch (Exception ex) { throw new PatchGenerationException(ex, "Failed to parse patch contents"); } } @Nonnull private static List deserializeRemovals(@Nonnull Workspace workspace, @Nonnull JsonReader jr) throws IOException, PatchGenerationException { List removals = new ArrayList<>(); jr.beginArray(); while (jr.hasNext()) { String name = null; String type = null; jr.beginObject(); while (jr.hasNext()) { String key = jr.nextName(); if (key.equals(KEY_NAME)) name = jr.nextString(); else if (key.equals(KEY_TYPE)) type = jr.nextString(); } jr.endObject(); // Construct the removal if (name != null) { // If the classes/files do not exist in the workspace then our job is already done, // and we don't need to include these in the final patch model. if (TYPE_CLASS.equals(type)) { FilePathNode path = workspace.findFile(name); if (path != null) removals.add(new RemovePath(path)); } else if (TYPE_FILE.equals(type)) { ClassPathNode path = workspace.findClass(name); if (path != null) removals.add(new RemovePath(path)); } } } jr.endArray(); return removals; } @Nonnull private static List deserializeClassJvmAsmDiffs(@Nonnull Workspace workspace, @Nonnull JsonReader jr) throws IOException, PatchGenerationException { List patches = new ArrayList<>(); jr.beginArray(); while (jr.hasNext()) { String name = null; List diffs = Collections.emptyList(); jr.beginObject(); while (jr.hasNext()) { String key = jr.nextName(); if (key.equals(KEY_NAME)) name = jr.nextString(); else if (key.equals(KEY_DIFFS)) diffs = deserializeStringDiffs(jr); } jr.endObject(); // Construct the patch if (name != null && !diffs.isEmpty()) { ClassPathNode classPath = workspace.findJvmClass(name); if (classPath == null) throw new PatchGenerationException("'" + name + "' cannot be found in the given workspace"); patches.add(new JvmAssemblerPatch(classPath, diffs)); } } jr.endArray(); return patches; } @Nonnull private static List deserializeFileTextDiffs(@Nonnull Workspace workspace, @Nonnull JsonReader jr) throws IOException, PatchGenerationException { List patches = new ArrayList<>(); jr.beginArray(); while (jr.hasNext()) { String name = null; List diffs = Collections.emptyList(); jr.beginObject(); while (jr.hasNext()) { String key = jr.nextName(); if (key.equals(KEY_NAME)) name = jr.nextString(); else if (key.equals(KEY_DIFFS)) diffs = deserializeStringDiffs(jr); } jr.endObject(); // Construct the patch if (name != null && !diffs.isEmpty()) { FilePathNode filePath = workspace.findFile(name); if (filePath == null) throw new PatchGenerationException("'" + name + "' cannot be found in the given workspace"); patches.add(new TextFilePatch(filePath, diffs)); } } jr.endArray(); return patches; } @Nonnull private static List deserializeStringDiffs(@Nonnull JsonReader jr) throws IOException { List diffs = new ArrayList<>(); jr.beginArray(); while (jr.hasNext()) diffs.add(deserializeStringDiff(jr)); jr.endArray(); return diffs; } @Nonnull private static StringDiff.Diff deserializeStringDiff(@Nonnull JsonReader jr) throws IOException { StringDiff.DiffType type = null; int startA = -1; int startB = -1; int endA = -1; int endB = -1; String textA = null; String textB = null; jr.beginObject(); while (jr.hasNext()) { String key = jr.nextName(); switch (key) { case KEY_TYPE -> type = StringDiff.DiffType.valueOf(jr.nextString()); case KEY_TEXT_A -> textA = jr.nextString(); case KEY_TEXT_B -> textB = jr.nextString(); case KEY_START_A -> startA = jr.nextInt(); case KEY_START_B -> startB = jr.nextInt(); case KEY_END_A -> endA = jr.nextInt(); case KEY_END_B -> endB = jr.nextInt(); } } jr.endObject(); if (type == null) throw new IOException("String diff missing key: " + KEY_TYPE); if (textA == null) throw new IOException("String diff missing key: " + KEY_TEXT_A); if (textB == null) throw new IOException("String diff missing key: " + KEY_TEXT_B); if (startA == -1) throw new IOException("String diff missing key: " + KEY_START_A); if (startB == -1) throw new IOException("String diff missing key: " + KEY_START_B); if (endA == -1) throw new IOException("String diff missing key: " + KEY_END_A); if (endB == -1) throw new IOException("String diff missing key: " + KEY_END_B); return new StringDiff.Diff(type, startA, startB, endA, endB, textA, textB); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/ResourcePatchApplierConfig.java ================================================ package software.coley.recaf.services.workspace.patch; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link PatchApplier}. * * @author Matt Coley */ @ApplicationScoped public class ResourcePatchApplierConfig extends BasicConfigContainer implements ServiceConfig { @Inject public ResourcePatchApplierConfig() { super(ConfigGroups.SERVICE_IO, PatchApplier.SERVICE_ID + CONFIG_SUFFIX); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/ResourcePatchProviderConfig.java ================================================ package software.coley.recaf.services.workspace.patch; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import software.coley.recaf.config.BasicConfigContainer; import software.coley.recaf.config.ConfigGroups; import software.coley.recaf.services.ServiceConfig; /** * Config for {@link PatchProvider}. * * @author Matt Coley */ @ApplicationScoped public class ResourcePatchProviderConfig extends BasicConfigContainer implements ServiceConfig { @Inject public ResourcePatchProviderConfig() { super(ConfigGroups.SERVICE_IO, PatchProvider.SERVICE_ID + CONFIG_SUFFIX); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/model/JvmAssemblerPatch.java ================================================ package software.coley.recaf.services.workspace.patch.model; import jakarta.annotation.Nonnull; import software.coley.recaf.path.ClassPathNode; import software.coley.recaf.util.StringDiff; import java.util.List; /** * Patches for a single text file. * * @param path * Path to the JVM class file to patch. * @param assemblerDiffs * Text patches to apply to the class via the assembler. * * @author Matt Coley */ public record JvmAssemblerPatch(@Nonnull ClassPathNode path, @Nonnull List assemblerDiffs) { @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; JvmAssemblerPatch that = (JvmAssemblerPatch) o; if (path.localCompare(that.path) != 0) return false; return assemblerDiffs.equals(that.assemblerDiffs); } @Override public int hashCode() { return assemblerDiffs.hashCode(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/model/RemovePath.java ================================================ package software.coley.recaf.services.workspace.patch.model; import jakarta.annotation.Nonnull; import software.coley.recaf.path.PathNode; /** * Outlines the removal of a given path. * * @param path * Path to the class/file to remove. * * @author Matt Coley */ public record RemovePath(@Nonnull PathNode path) {} ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/model/TextFilePatch.java ================================================ package software.coley.recaf.services.workspace.patch.model; import jakarta.annotation.Nonnull; import software.coley.recaf.path.FilePathNode; import software.coley.recaf.util.StringDiff; import java.util.List; /** * Patches for a single text file. * * @param path * Path to the text file to patch. * @param textDiffs * Text patches to apply to the text file. * * @author Matt Coley */ public record TextFilePatch(@Nonnull FilePathNode path, @Nonnull List textDiffs) { @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; TextFilePatch that = (TextFilePatch) o; if (path.localCompare(that.path) != 0) return false; return textDiffs.equals(that.textDiffs); } @Override public int hashCode() { return textDiffs.hashCode(); } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/model/WorkspacePatch.java ================================================ package software.coley.recaf.services.workspace.patch.model; import jakarta.annotation.Nonnull; import software.coley.recaf.workspace.model.Workspace; import java.util.List; /** * Wrapper of various patches to apply to a workspace. * * @param workspace * Workspace to apply the patches to. * @param removals * Removal patches to remove content by paths. * @param jvmAssemblerPatches * Text patches to apply to JVM classes via the assembler. * @param textFilePatches * Text patches to apply to text files. * * @author Matt Coley */ public record WorkspacePatch(@Nonnull Workspace workspace, @Nonnull List removals, @Nonnull List jvmAssemblerPatches, @Nonnull List textFilePatches) { // TODO: Add more patch type lists // - New classes / files // - Special patch types for specific transformations like: // - "replace this method with 'return 0'" // - "change this method's modifiers" // - Can be JVM/Android agnostic @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; WorkspacePatch that = (WorkspacePatch) o; if (!jvmAssemblerPatches.equals(that.jvmAssemblerPatches)) return false; return textFilePatches.equals(that.textFilePatches); } @Override public int hashCode() { int result = jvmAssemblerPatches.hashCode(); result = 31 * result + textFilePatches.hashCode(); return result; } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/services/workspace/processors/ThrowablePropertyAssigningProcessor.java ================================================ package software.coley.recaf.services.workspace.processors; import jakarta.annotation.Nonnull; import jakarta.enterprise.context.Dependent; import jakarta.inject.Inject; import software.coley.recaf.info.AndroidClassInfo; import software.coley.recaf.info.ClassInfo; import software.coley.recaf.info.JvmClassInfo; import software.coley.recaf.info.properties.builtin.ThrowableProperty; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.services.inheritance.InheritanceGraphService; import software.coley.recaf.services.inheritance.InheritanceVertex; import software.coley.recaf.services.workspace.WorkspaceProcessor; import software.coley.recaf.util.threading.ThreadUtil; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.WorkspaceModificationListener; import software.coley.recaf.workspace.model.bundle.AndroidClassBundle; import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import software.coley.recaf.workspace.model.resource.ResourceAndroidClassListener; import software.coley.recaf.workspace.model.resource.ResourceJvmClassListener; import software.coley.recaf.workspace.model.resource.WorkspaceResource; import java.util.concurrent.CompletableFuture; /** * Workspace processor that marks {@link ClassInfo} values that inherit from {@link Throwable} * as having a {@link ThrowableProperty}. This allows instant look-ups for if a class is throwable, * by bypassing repeated calls to {@link InheritanceGraph}. * * @author Matt Coley */ @Dependent public class ThrowablePropertyAssigningProcessor implements WorkspaceProcessor, ResourceJvmClassListener, ResourceAndroidClassListener { private static final String THROWABLE = "java/lang/Throwable"; private final InheritanceGraphService graphService; private InheritanceGraph inheritanceGraph; @Inject public ThrowablePropertyAssigningProcessor(@Nonnull InheritanceGraphService graphService) { this.graphService = graphService; } @Override public void processWorkspace(@Nonnull Workspace workspace) { // Ensure future changes to workspace will process any new classes. ThrowablePropertyAssigningProcessor processor = this; workspace.addWorkspaceModificationListener(new WorkspaceModificationListener() { @Override public void onAddLibrary(@Nonnull Workspace workspace, @Nonnull WorkspaceResource library) { library.addListener(processor); } @Override public void onRemoveLibrary(@Nonnull Workspace workspace, @Nonnull WorkspaceResource library) { library.removeListener(processor); } }); for (WorkspaceResource resource : workspace.getAllResources(false)) resource.addListener(processor); // Provide a graph asynchronously, then process all classes in the workspace. CompletableFuture.supplyAsync(() -> graphService.getOrCreateInheritanceGraph(workspace), ThreadUtil.executor()) .thenAccept(inheritanceGraph -> { this.inheritanceGraph = inheritanceGraph; workspace.findClasses(false, c -> true).forEach(path -> handle(path.getValue())); }); } @Nonnull @Override public String name() { return "Mark throwable types"; } private void handle(@Nonnull ClassInfo cls) { // Skip if not ready yet. // Any 'missed' cases here should be satisfied by the initial pass when the graph becomes available. if (inheritanceGraph == null) return; // Mark the class if it has 'java/lang/Throwable' as a parent. InheritanceVertex vertex = inheritanceGraph.getVertex(cls.getName()); if (vertex != null && vertex.hasParent(THROWABLE)) ThrowableProperty.set(cls); } @Override public void onNewClass(@Nonnull WorkspaceResource resource, @Nonnull AndroidClassBundle bundle, @Nonnull AndroidClassInfo cls) { handle(cls); } @Override public void onUpdateClass(@Nonnull WorkspaceResource resource, @Nonnull AndroidClassBundle bundle, @Nonnull AndroidClassInfo oldCls, @Nonnull AndroidClassInfo newCls) { handle(newCls); } @Override public void onRemoveClass(@Nonnull WorkspaceResource resource, @Nonnull AndroidClassBundle bundle, @Nonnull AndroidClassInfo cls) { // no-op } @Override public void onNewClass(@Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo cls) { handle(cls); } @Override public void onUpdateClass(@Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo oldCls, @Nonnull JvmClassInfo newCls) { handle(newCls); } @Override public void onRemoveClass(@Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, @Nonnull JvmClassInfo cls) { // no-op } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/util/AccessFlag.java ================================================ package software.coley.recaf.util; import com.google.common.collect.Multimap; import com.google.common.collect.MultimapBuilder; import com.google.common.collect.SetMultimap; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.objectweb.asm.Opcodes; import java.util.*; import java.util.stream.Collectors; import java.util.stream.StreamSupport; /** * Utility for handling access flags/modifiers. * * @author Matt Coley * @author Andy Li */ public enum AccessFlag { ACC_PUBLIC(Opcodes.ACC_PUBLIC, "public", true, Type.CLASS, Type.INNER_CLASS, Type.METHOD, Type.FIELD), ACC_PRIVATE(Opcodes.ACC_PRIVATE, "private", true, Type.INNER_CLASS, Type.METHOD, Type.FIELD), ACC_PROTECTED(Opcodes.ACC_PROTECTED, "protected", true, Type.INNER_CLASS, Type.METHOD, Type.FIELD), ACC_STATIC(Opcodes.ACC_STATIC, "static", true, Type.INNER_CLASS, Type.METHOD, Type.FIELD), ACC_FINAL(Opcodes.ACC_FINAL, "final", true, Type.CLASS, Type.INNER_CLASS, Type.METHOD, Type.FIELD, Type.PARAM), ACC_SYNCHRONIZED(Opcodes.ACC_SYNCHRONIZED, "synchronized", true, Type.METHOD), ACC_SUPER(Opcodes.ACC_SUPER, "super", false, Type.CLASS), ACC_BRIDGE(Opcodes.ACC_BRIDGE, "bridge", false, Type.METHOD), ACC_VOLATILE(Opcodes.ACC_VOLATILE, "volatile", true, Type.FIELD), ACC_VARARGS(Opcodes.ACC_VARARGS, "varargs", false, Type.METHOD), ACC_TRANSIENT(Opcodes.ACC_TRANSIENT, "transient", true, Type.FIELD), ACC_NATIVE(Opcodes.ACC_NATIVE, "native", true, Type.METHOD), ACC_INTERFACE(Opcodes.ACC_INTERFACE, "interface", true, Type.CLASS, Type.INNER_CLASS), ACC_ABSTRACT(Opcodes.ACC_ABSTRACT, "abstract", true, Type.CLASS, Type.INNER_CLASS, Type.METHOD), ACC_STRICT(Opcodes.ACC_STRICT, "strictfp", true, Type.METHOD), ACC_SYNTHETIC(Opcodes.ACC_SYNTHETIC, "synthetic", false, Type.CLASS, Type.INNER_CLASS, Type.METHOD, Type.FIELD, Type.PARAM), ACC_ANNOTATION(Opcodes.ACC_ANNOTATION, "annotation-interface", false, Type.CLASS, Type.INNER_CLASS), ACC_ENUM(Opcodes.ACC_ENUM, "enum", true, Type.CLASS, Type.INNER_CLASS, Type.FIELD), ACC_OPEN(Opcodes.ACC_OPEN, "open", false, Type.MODULE), ACC_STATIC_PHASE(Opcodes.ACC_STATIC_PHASE, "static-phase", false, Type.MODULE), ACC_TRANSITIVE(Opcodes.ACC_TRANSITIVE, "transitive", false, Type.MODULE), ACC_MODULE(Opcodes.ACC_MODULE, "module", false, Type.CLASS), ACC_MANDATED(Opcodes.ACC_MANDATED, "mandated", false, Type.PARAM); private static final Multimap maskToFlagsMap; private static final Map nameToFlagMap; private static final Multimap typeToFlagsMap; static { AccessFlag[] flags = values(); SetMultimap maskMap = MultimapBuilder.hashKeys() .enumSetValues(AccessFlag.class).build(); Map nameMap = new LinkedHashMap<>(flags.length); SetMultimap typeMap = MultimapBuilder.enumKeys(Type.class) .enumSetValues(AccessFlag.class).build(); for (AccessFlag flag : flags) { maskMap.put(flag.mask, flag); nameMap.put(flag.name, flag); flag.types.forEach(type -> typeMap.put(type, flag)); } maskToFlagsMap = maskMap; nameToFlagMap = nameMap; typeToFlagsMap = typeMap; Type.populateOrder(); // lazy load } /** * @param mask * Access flags mask. * * @return Set of applicable flags. Some flags can have different meanings in different contexts. */ @Nonnull public static Collection getFlags(int mask) { return maskToFlagsMap.get(mask); } /** * @param type * Flag type. * * @return Set of flags that belong to the type group. */ @Nonnull public static Collection getApplicableFlags(@Nonnull Type type) { return typeToFlagsMap.get(type); } /** * @param type * Flag type. * @param acc * Access flag mask. * * @return Set of flags that belong to the type group in the access flag mask. */ @Nonnull public static Set getApplicableFlags(@Nonnull Type type, int acc) { Set flags = EnumSet.noneOf(AccessFlag.class); for (AccessFlag applicableFlag : getApplicableFlags(type)) if (applicableFlag.has(acc)) flags.add(applicableFlag); return flags; } /** * @param type * Flag type. * @param flags * Collection of flags. * * @return Sorted order of flags. */ @Nonnull public static List sort(@Nonnull Type type, @Nonnull Collection flags) { List list; if (flags instanceof ArrayList) { list = (List) flags; } else { list = new ArrayList<>(flags); } list.sort(type.recommendOrderComparator); return list; } /** * @param name * Name of flag. * * @return Flag from name. */ @Nullable public static AccessFlag getFlag(@Nullable String name) { return nameToFlagMap.get(name); } /** * @param text * Text containing one or more flags, separated by spaces. * * @return Flags from text. */ @Nonnull public static List getFlags(@Nonnull String text) { String[] parts = text.split("\\s+"); List flags = new ArrayList<>(); for (String part : parts) { AccessFlag flag = getFlag(part); if (flag != null) flags.add(flag); } return flags; } /** * @param acc * Access flag mask. * @param flag * Flag to remove. * * @return Updated flag mask. */ public static int removeFlag(int acc, int flag) { return acc & ~flag; } /** * @param flags * Array of access flags. * * @return Access flag mask. */ public static int createAccess(AccessFlag... flags) { int acc = 0; for (AccessFlag flag : flags) acc = flag.set(acc); return acc; } /** * @param acc * Access flag mask. * @param flags * Array of flags to check exist in the mask. * * @return {@code true} if all specified flags exist in the mask. */ public static boolean hasAll(int acc, AccessFlag... flags) { for (AccessFlag flag : flags) { if (!flag.has(acc)) return false; } return true; } /** * @param acc * Access flag mask. * @param flags * Collection of flags to check exist in the mask. * * @return {@code true} if all specified flags exist in the mask. */ public static boolean hasAll(int acc, Collection flags) { for (AccessFlag flag : flags) { if (!flag.has(acc)) return false; } return true; } /** * @param acc * Access flag mask. * @param flags * Array of flags to check exist in the mask. * * @return {@code true} if none of the specified flags exist in the mask. */ public static boolean hasNone(int acc, AccessFlag... flags) { for (AccessFlag flag : flags) { if (flag.has(acc)) return false; } return true; } /** * @param acc * Access flag mask. * @param flags * Collection of flags to check exist in the mask. * * @return {@code true} if none of the specified flags exist in the mask. */ public static boolean hasNone(int acc, @Nonnull Collection flags) { for (AccessFlag flag : flags) { if (flag.has(acc)) return false; } return true; } /** * @param acc * Access flag mask. * @param flags * Array of flags to check exist in the mask. * * @return {@code true} if any of the specified flags exist in the mask. */ public static boolean hasAny(int acc, AccessFlag... flags) { for (AccessFlag flag : flags) { if (flag.has(acc)) return true; } return false; } /** * @param acc * Access flag mask. * @param flags * Collection of flags to check exist in the mask. * * @return {@code true} if any of the specified flags exist in the mask. */ public static boolean hasAny(int acc, @Nonnull Collection flags) { for (AccessFlag flag : flags) { if (flag.has(acc)) return true; } return false; } /** * Flag mask value. */ private final int mask; /** * Flag identifier. */ private final String name; /** * If the flag is treated as a keyword by the Java compiler. */ private final boolean isKeyword; /** * Applicable flag type groups. */ private final Set types; AccessFlag(int mask, String name, boolean isKeyword, Set types) { this.mask = mask; this.name = name; this.isKeyword = isKeyword; this.types = Collections.unmodifiableSet(types); } AccessFlag(int mask, String name, boolean isKeyword, Type type) { this(mask, name, isKeyword, Collections.singleton(type)); } AccessFlag(int mask, String name, boolean isKeyword, Type firstType, Type... restTypes) { this(mask, name, isKeyword, EnumSet.of(firstType, restTypes)); } /** * @return Access flag mask. */ public int getMask() { return mask; } /** * @param access * Access flag mask. * * @return {@code true} if the flag contains the mask. */ public boolean has(int access) { return (access & mask) != 0; } /** * @param access * Access flag mask. * * @return Mask combined with the given access flag mask. */ public int set(int access) { return access | mask; } /** * @param access * Access flag mask. * * @return Mask without the current flag. */ public int clear(int access) { return access & (~mask); } /** * @return Applicable targets for the current flag. */ @Nonnull public Set getTypes() { return types; } /** * @return Flag identifier. */ @Nonnull public String getName() { return name; } @Override public String toString() { return getCodeFriendlyName(); } /** * @param flags * Collection of flags. * * @return String representation of flags. */ @Nonnull public static String toString(@Nonnull Iterable flags) { // Don't include ACC_SUPER, is meaningless return StreamSupport.stream(flags.spliterator(), false) .filter(Objects::nonNull) .filter(v -> v != ACC_SUPER) .map(AccessFlag::toString) .collect(Collectors.joining(" ")); } /** * @param type * Flag type. * @param flags * Collection of flags. * * @return String representation of flags in sorted order. */ @Nonnull public static String sortAndToString(@Nonnull Type type, @Nonnull Collection flags) { List list = sort(type, flags); return toString(list); } /** * @param type * Flag type. * @param acc * Access flag mask. * * @return String representation of flags in sorted order. */ @Nonnull public static String sortAndToString(@Nonnull Type type, int acc) { return sortAndToString(type, getApplicableFlags(type, acc)); } /** * @return Flag identifier with surrounding comments if the identifier is not a Java keyword. */ @Nonnull public String getCodeFriendlyName() { return isKeyword ? this.name : "/* " + name + " */"; // comment out non-keyword } /** * @param acc * Access flag mask. * * @return {@code true} when the mask contains the public flag. */ public static boolean isPublic(int acc) { return ACC_PUBLIC.has(acc); } /** * @param acc * Access flag mask. * * @return {@code true} when the mask contains the private flag. */ public static boolean isPrivate(int acc) { return ACC_PRIVATE.has(acc); } /** * @param acc * Access flag mask. * * @return {@code true} when the mask contains the protected flag. */ public static boolean isProtected(int acc) { return ACC_PROTECTED.has(acc); } /** * @param acc * Access flag mask. * * @return {@code true} when the mask contains neither a private, protected nor public flag. */ public static boolean isPackage(int acc) { return !isPrivate(acc) && !isProtected(acc) && !isPublic(acc); } /** * @param acc * Access flag mask. * * @return {@code true} when the mask contains the static flag. */ public static boolean isStatic(int acc) { return ACC_STATIC.has(acc); } /** * @param acc * Access flag mask. * * @return {@code true} when the mask contains the final flag. */ public static boolean isFinal(int acc) { return ACC_FINAL.has(acc); } /** * @param acc * Access flag mask. * * @return {@code true} when the mask contains the synchronized flag. */ public static boolean isSynchronized(int acc) { return ACC_SYNCHRONIZED.has(acc); } /** * @param acc * Access flag mask. * * @return {@code true} when the mask contains the super flag. */ public static boolean isSuper(int acc) { return ACC_SUPER.has(acc); } /** * @param acc * Access flag mask. * * @return {@code true} when the mask contains the bridge flag. */ public static boolean isBridge(int acc) { return ACC_BRIDGE.has(acc); } /** * @param acc * Access flag mask. * * @return {@code true} when the mask contains the volatile flag. */ public static boolean isVolatile(int acc) { return ACC_VOLATILE.has(acc); } /** * @param acc * Access flag mask. * * @return {@code true} when the mask contains the varargs flag. */ public static boolean isVarargs(int acc) { return ACC_VARARGS.has(acc); } /** * @param acc * Access flag mask. * * @return {@code true} when the mask contains the transient flag. */ public static boolean isTransient(int acc) { return ACC_TRANSIENT.has(acc); } /** * @param acc * Access flag mask. * * @return {@code true} when the mask contains the native flag. */ public static boolean isNative(int acc) { return ACC_NATIVE.has(acc); } /** * @param acc * Access flag mask. * * @return {@code true} when the mask contains the interface flag. */ public static boolean isInterface(int acc) { return ACC_INTERFACE.has(acc); } /** * @param acc * Access flag mask. * * @return {@code true} when the mask contains the abstract flag. */ public static boolean isAbstract(int acc) { return ACC_ABSTRACT.has(acc); } /** * @param acc * Access flag mask. * * @return {@code true} when the mask contains the strict (Floating point math) flag. */ public static boolean isStrict(int acc) { return ACC_STRICT.has(acc); } /** * @param acc * Access flag mask. * * @return {@code true} when the mask contains the synthetic flag. */ public static boolean isSynthetic(int acc) { return ACC_SYNTHETIC.has(acc); } /** * @param acc * Access flag mask. * * @return {@code true} when the mask contains the annotation flag. */ public static boolean isAnnotation(int acc) { return ACC_ANNOTATION.has(acc); } /** * @param acc * Access flag mask. * * @return {@code true} when the mask contains the enum flag. */ public static boolean isEnum(int acc) { return ACC_ENUM.has(acc); } /** * @param acc * Access flag mask. * * @return {@code true} when the mask contains the module flag. */ public static boolean isModule(int acc) { return ACC_MODULE.has(acc); } /** * @param acc * Access flag mask. * * @return {@code true} when the mask contains the mandated flag. */ public static boolean isMandated(int acc) { return ACC_MANDATED.has(acc); } /** * Flag group. */ public enum Type { CLASS("public abstract final strictfp interface annotation-interface enum module"), INNER_CLASS("public protected private abstract static final strictfp interface annotation-interface enum module"), METHOD("public protected private abstract static final synchronized native strictfp"), FIELD("public protected private static final transient volatile"), MODULE("open mandated synthetic mandated"), PARAM("final"); private final String order; private final List orderList = new ArrayList<>(); /** * Unmodifiable view of `orderList` */ public final List recommendOrder = Collections.unmodifiableList(orderList); /** * Comparator to sort flags by their recommended ordering. */ public final Comparator recommendOrderComparator; Type(String recommendOrder) { this.order = recommendOrder; this.recommendOrderComparator = Comparator.comparingInt(this::index); } private int index(AccessFlag flag) { if (recommendOrder.isEmpty()) return 0; // not initialized yet int idx = recommendOrder.indexOf(flag); return idx == -1 ? Integer.MAX_VALUE : idx; } private static void populateOrder() { // lazy load for (Type type : values()) { List orderList = type.orderList; orderList.clear(); orderList.addAll(parseModifierOrder(type.order)); } } private static List parseModifierOrder(String string) { if (string == null) return Collections.emptyList(); return Arrays.stream(string.split(" ")) .map(nameToFlagMap::get) .map(Objects::requireNonNull) .collect(Collectors.toList()); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/util/AccessPatcher.java ================================================ package software.coley.recaf.util; import org.slf4j.Logger; import software.coley.recaf.analytics.SystemInformation; import software.coley.recaf.analytics.logging.Logging; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; /** * Utility to patch away access restrictions. *

* You must initialize {@link ReflectUtil} first! * * @author xDark */ class AccessPatcher { private static final Logger logger = Logging.get(AccessPatcher.class); private static boolean patched; // Deny all constructions. private AccessPatcher() { } /** * Patches JDK access restrictions. */ static void patch() { if (patched) return; try { openPackages(); patchReflectionFilters(); } catch (Throwable t) { logger.error("Failed access patching on Java " + SystemInformation.JAVA_VERSION + "(" + SystemInformation.JAVA_VM_VENDOR + ")", t); } finally { patched = true; } } /** * Opens all packages. */ private static void openPackages() { try { Class context = AccessPatcher.class; Set modules = new HashSet<>(); Module base = context.getModule(); ModuleLayer baseLayer = base.getLayer(); if (baseLayer != null) modules.addAll(baseLayer.modules()); modules.addAll(ModuleLayer.boot().modules()); for (ClassLoader cl = context.getClassLoader(); cl != null; cl = cl.getParent()) modules.add(cl.getUnnamedModule()); MethodHandle export = ReflectUtil.lookup().findVirtual(Module.class, "implAddOpens", MethodType.methodType(void.class, String.class)); for (Module module : modules) { for (String name : module.getPackages()) { try { export.invokeExact(module, name); } catch (Exception ex) { logger.error("Could not export package {} in module {}", name, module); logger.error("Root cause: ", ex); } } } } catch (Throwable t) { throw new IllegalStateException("Could not export packages", t); } } /** * Patches reflection filters. */ private static void patchReflectionFilters() { Class klass; try { klass = Class.forName("jdk.internal.reflect.Reflection", true, null); } catch (ClassNotFoundException ex) { throw new IllegalStateException("Unable to locate 'jdk.internal.reflect.Reflection' class", ex); } try { MethodHandles.Lookup lookup = ReflectUtil.lookup(); lookup.findStaticSetter(klass, "fieldFilterMap", Map.class).invokeExact((Map) new HashMap<>()); lookup.findStaticSetter(klass, "methodFilterMap", Map.class).invokeExact((Map) new HashMap<>()); } catch (Throwable t) { throw new IllegalStateException("Unable to patch reflection filters", t); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/util/AsmInsnUtil.java ================================================ package software.coley.recaf.util; import it.unimi.dsi.fastutil.ints.Int2ObjectMap; import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import me.darknet.assembler.util.BlwOpcodes; import org.objectweb.asm.Handle; import org.objectweb.asm.Opcodes; import org.objectweb.asm.Type; import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.FieldInsnNode; import org.objectweb.asm.tree.InsnList; import org.objectweb.asm.tree.InsnNode; import org.objectweb.asm.tree.IntInsnNode; import org.objectweb.asm.tree.InvokeDynamicInsnNode; import org.objectweb.asm.tree.JumpInsnNode; import org.objectweb.asm.tree.LabelNode; import org.objectweb.asm.tree.LdcInsnNode; import org.objectweb.asm.tree.LocalVariableNode; import org.objectweb.asm.tree.LookupSwitchInsnNode; import org.objectweb.asm.tree.MethodInsnNode; import org.objectweb.asm.tree.MethodNode; import org.objectweb.asm.tree.MultiANewArrayInsnNode; import org.objectweb.asm.tree.TableSwitchInsnNode; import org.objectweb.asm.tree.TryCatchBlockNode; import org.objectweb.asm.tree.VarInsnNode; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.BitSet; import java.util.Collections; import java.util.Deque; import java.util.HashMap; import java.util.IdentityHashMap; import java.util.List; import java.util.Map; import java.util.Set; import static java.util.Collections.emptyList; /** * ASM instruction utilities. * * @author Matt Coley */ public class AsmInsnUtil implements Opcodes { private static final Map opcodeToName = new HashMap<>(); static { BlwOpcodes.getOpcodes().forEach((name, op) -> opcodeToName.put(op, name)); } /** * @param opcode * Some opcode. * * @return Name of opcode. */ @Nonnull public static String getInsnName(int opcode) { return opcodeToName.getOrDefault(opcode, "unknown"); } /** * Convert an instruction opcode to a {@link Handle} tag. * * @param opcode * Some method or field opcode. * * @return Relevant handle tag. * * @throws IllegalStateException * When the opcode does not have a respective handle tag. */ public static int opcodeToTag(int opcode) { return switch (opcode) { case INVOKEINTERFACE -> H_INVOKEINTERFACE; case INVOKESPECIAL -> H_INVOKESPECIAL; case INVOKEVIRTUAL -> H_INVOKEVIRTUAL; case INVOKESTATIC -> H_INVOKESTATIC; case GETFIELD -> H_GETFIELD; case GETSTATIC -> H_GETSTATIC; case PUTFIELD -> H_PUTFIELD; case PUTSTATIC -> H_PUTSTATIC; default -> throw new IllegalStateException("Unsupported opcode: " + opcode); }; } /** * Convert a {@link Handle} tag to an instruction opcode. * * @param tag * Some method or field handle tag. * * @return Relevant instruction opcode. * * @throws IllegalStateException * When the tag does not have a respective handle opcode. */ public static int tagToOpcode(int tag) { return switch (tag) { case H_INVOKEINTERFACE -> INVOKEINTERFACE; case H_INVOKESPECIAL -> INVOKESPECIAL; case H_INVOKEVIRTUAL -> INVOKEVIRTUAL; case H_INVOKESTATIC -> INVOKESTATIC; case H_GETFIELD -> GETFIELD; case H_GETSTATIC -> GETSTATIC; case H_PUTFIELD -> PUTFIELD; case H_PUTSTATIC -> PUTSTATIC; default -> throw new IllegalStateException("Unsupported tag: " + tag); }; } /** * @param insn * Instruction to get index of. * * @return Index of instruction in containing method. */ public static int indexOf(@Nonnull AbstractInsnNode insn) { int i = 0; while ((insn = insn.getPrevious()) != null) i++; return i; } /** * @param varInsn * Variable instruction. * * @return Type that encompasses the variable being accessed/written to, */ @Nonnull public static Type getTypeForVarInsn(@Nonnull VarInsnNode varInsn) { return switch (varInsn.getOpcode()) { case Opcodes.ISTORE, Opcodes.ILOAD -> Type.INT_TYPE; case Opcodes.LSTORE, Opcodes.LLOAD -> Type.LONG_TYPE; case Opcodes.FSTORE, Opcodes.FLOAD -> Type.FLOAT_TYPE; case Opcodes.DSTORE, Opcodes.DLOAD -> Type.DOUBLE_TYPE; default -> Types.OBJECT_TYPE; }; } /** * @param op * Instruction opcode. * * @return {@code true} when it is any variable storing instruction. */ public static boolean isVarStore(int op) { return op >= ISTORE && op <= ASTORE; } /** * @param op * Instruction opcode. * * @return {@code true} when it is any variable loading instruction. */ public static boolean isVarLoad(int op) { return op >= ILOAD && op <= ALOAD; } /** * @param index * Variable index. * @param variableType * Variable type. * * @return Load instruction for variable type at the given index. */ @Nonnull public static VarInsnNode createVarLoad(int index, @Nonnull Type variableType) { return createVarLoad(index, variableType.getSort()); } /** * @param index * Variable index. * @param typeSort * Variable type sort. * * @return Load instruction for variable type at the given index. */ @Nonnull public static VarInsnNode createVarLoad(int index, int typeSort) { return switch (typeSort) { case Type.BOOLEAN, Type.CHAR, Type.BYTE, Type.SHORT, Type.INT -> new VarInsnNode(ILOAD, index); case Type.FLOAT -> new VarInsnNode(FLOAD, index); case Type.DOUBLE -> new VarInsnNode(DLOAD, index); case Type.LONG -> new VarInsnNode(LLOAD, index); default -> new VarInsnNode(ALOAD, index); }; } /** * @param index * Variable index. * @param variableType * Variable type. * * @return Store instruction for variable type at the given index. */ @Nonnull public static VarInsnNode createVarStore(int index, @Nonnull Type variableType) { return switch (variableType.getSort()) { case Type.BOOLEAN, Type.CHAR, Type.BYTE, Type.SHORT, Type.INT -> new VarInsnNode(ISTORE, index); case Type.FLOAT -> new VarInsnNode(FSTORE, index); case Type.DOUBLE -> new VarInsnNode(DSTORE, index); case Type.LONG -> new VarInsnNode(LSTORE, index); default -> new VarInsnNode(ASTORE, index); }; } /** * @param access * Method access flags. * * @return Invoke opcode for method with the given access flags. */ public static int getInvokeForMethod(int access) { if ((access & Opcodes.ACC_STATIC) != 0) return INVOKESTATIC; if ((access & Opcodes.ACC_INTERFACE) != 0) return INVOKEINTERFACE; if ((access & Opcodes.ACC_PRIVATE) != 0) return INVOKESPECIAL; return INVOKEVIRTUAL; } /** * Checks in the given method for local vars that have label references * that do not exist in the method's instructions list. * * @param method * Method to fix local variables of. */ public static void fixMissingVariableLabels(@Nonnull MethodNode method) { // Must not be abstract InsnList instructions = method.instructions; if (instructions == null || instructions.size() == 0) return; // Must have variables to fix List variables = method.localVariables; if (variables == null || variables.isEmpty()) return; // Find or create first/last labels LabelNode firstLabel = null; LabelNode lastLabel = null; for (int i = 0; i < instructions.size(); i++) if (instructions.get(i) instanceof LabelNode label) { firstLabel = label; break; } for (int i = instructions.size() - 1; i >= 0; i--) if (instructions.get(i) instanceof LabelNode label) { lastLabel = label; break; } if (firstLabel == null) instructions.insert(firstLabel = new LabelNode()); if (lastLabel == null || lastLabel == firstLabel) instructions.add(lastLabel = new LabelNode()); // Find any variables that have invalid labels and reassign them if needed for (LocalVariableNode variable : variables) { int start = instructions.indexOf(variable.start); int end = instructions.indexOf(variable.end); // Variable start must be a valid label in the method, and occur before the end label if (start < 0 || start > end) variable.start = firstLabel; // End label must be a valid label in the method if (end < 0) variable.end = lastLabel; } } /** * @param op * Instruction opcode. * * @return {@code true} if the instruction pushes a constant value onto the stack. */ public static boolean isConstValue(int op) { return op >= ACONST_NULL && op <= LDC; } /** * @param insn * Instruction to check. * * @return {@code true} if the instruction pushes a constant {@code int} value onto the stack. */ public static boolean isConstIntValue(@Nonnull AbstractInsnNode insn) { int op = insn.getOpcode(); if (op == LDC && ((LdcInsnNode) insn).cst instanceof Integer) return true; return (op >= ICONST_M1 && op <= ICONST_5) || op == SIPUSH || op == BIPUSH; } /** * @param type * Type to push. * * @return Instruction to push a default value of the given type onto the stack. */ @Nonnull public static AbstractInsnNode getDefaultValue(@Nonnull Type type) { return switch (type.getSort()) { case Type.BOOLEAN, Type.CHAR, Type.BYTE, Type.SHORT, Type.INT -> new InsnNode(ICONST_0); case Type.LONG -> new InsnNode(LCONST_0); case Type.FLOAT -> new InsnNode(FCONST_0); case Type.DOUBLE -> new InsnNode(DCONST_0); default -> new InsnNode(ACONST_NULL); }; } /** * Create an instruction to hold a given {@code int} value. * * @param value * Value to hold. * * @return Insn with const value. */ @Nonnull public static AbstractInsnNode intToInsn(int value) { switch (value) { case -1: return new InsnNode(ICONST_M1); case 0: return new InsnNode(ICONST_0); case 1: return new InsnNode(ICONST_1); case 2: return new InsnNode(ICONST_2); case 3: return new InsnNode(ICONST_3); case 4: return new InsnNode(ICONST_4); case 5: return new InsnNode(ICONST_5); default: if (value >= Byte.MIN_VALUE && value <= Byte.MAX_VALUE) { return new IntInsnNode(BIPUSH, value); } else if (value >= Short.MIN_VALUE && value <= Short.MAX_VALUE) { return new IntInsnNode(SIPUSH, value); } else { return new LdcInsnNode(value); } } } /** * Create an instruction to hold a given {@code float} value. * * @param value * Value to hold. * * @return Insn with const value. */ @Nonnull public static AbstractInsnNode floatToInsn(float value) { if (value == 0) return new InsnNode(FCONST_0); if (value == 1) return new InsnNode(FCONST_1); if (value == 2) return new InsnNode(FCONST_2); return new LdcInsnNode(value); } /** * Create an instruction to hold a given {@code double} value. * * @param value * Value to hold. * * @return Insn with const value. */ @Nonnull public static AbstractInsnNode doubleToInsn(double value) { if (value == 0) return new InsnNode(DCONST_0); if (value == 1) return new InsnNode(DCONST_1); return new LdcInsnNode(value); } /** * Create an instruction to hold a given {@code long} value. * * @param value * Value to hold. * * @return Insn with const value. */ @Nonnull public static AbstractInsnNode longToInsn(long value) { if (value == 0) return new InsnNode(LCONST_0); if (value == 1) return new InsnNode(LCONST_1); return new LdcInsnNode(value); } /** * @param type * Some type. * * @return Method return instruction opcode for the given type. */ public static int getReturnOpcode(@Nonnull Type type) { return switch (type.getSort()) { case Type.VOID -> RETURN; case Type.BOOLEAN, Type.CHAR, Type.BYTE, Type.SHORT, Type.INT -> IRETURN; case Type.FLOAT -> FRETURN; case Type.LONG -> LRETURN; case Type.DOUBLE -> DRETURN; default -> ARETURN; }; } /** * @param insn * Instruction to check. * * @return {@code true} when it is a return operation. */ public static boolean isReturn(@Nullable AbstractInsnNode insn) { if (insn == null) return false; return isReturn(insn.getOpcode()); } /** * @param op * Instruction opcode. * * @return {@code true} when it is a return operation. */ public static boolean isReturn(int op) { return switch (op) { case IRETURN, LRETURN, FRETURN, DRETURN, ARETURN, RETURN -> true; default -> false; }; } /** * @param insn * Instruction to check. * * @return {@code true} when it is a label. */ public static boolean isLabel(@Nullable AbstractInsnNode insn) { if (insn == null) return false; return insn.getType() == AbstractInsnNode.LABEL; } /** * @param insn * Instruction to check. * * @return {@code true} if the instruction modifies the control flow. */ public static boolean isFlowControl(@Nullable AbstractInsnNode insn) { if (insn == null) return false; int type = insn.getType(); return type == AbstractInsnNode.JUMP_INSN || type == AbstractInsnNode.TABLESWITCH_INSN || type == AbstractInsnNode.LOOKUPSWITCH_INSN || insn.getOpcode() == ATHROW || insn.getOpcode() == RET; } /** * Any instruction that is matched by this should be safe to use as the last instruction in a method. * If the last instruction in a method yields {@code false} then there is dangling control flow and * the code is not verifier compatible. * * @param op * Instruction opcode. * * @return {@code true} when the opcode represents an instruction that * terminates the method flow, or consistently takes a branch. */ public static boolean isTerminalOrAlwaysTakeFlowControl(int op) { return switch (op) { case IRETURN, LRETURN, FRETURN, DRETURN, ARETURN, RETURN, ATHROW, TABLESWITCH, LOOKUPSWITCH, GOTO, JSR -> true; default -> false; }; } /** * @param switchInsn * Switch instruction. * * @return {@code true} when all destinations are identical. */ public static boolean isSwitchEffectiveGoto(@Nonnull TableSwitchInsnNode switchInsn) { LabelNode target = switchInsn.dflt; for (LabelNode label : switchInsn.labels) if (label != target) return false; return true; } /** * @param switchInsn * Switch instruction. * * @return {@code true} when all destinations are identical. */ public static boolean isSwitchEffectiveGoto(@Nonnull LookupSwitchInsnNode switchInsn) { LabelNode target = switchInsn.dflt; for (LabelNode label : switchInsn.labels) if (label != target) return false; return true; } /** * @param insn * Instruction to check. * * @return {@code true} if the instruction represents metadata such as line numbers, stack frames, or a label/offset. */ public static boolean isMetaData(@Nonnull AbstractInsnNode insn) { // The following instruction types set their opcode as '-1' // - FrameNode // - LabelNode // - LineNumberNode return insn.getOpcode() == -1; } /** * @param insn * Instruction to begin from. * * @return Next non-metadata instruction. * Can be {@code null} for no next instruction at the end of a method. */ @Nullable public static AbstractInsnNode getNextInsn(@Nonnull AbstractInsnNode insn) { AbstractInsnNode next = insn.getNext(); while (next != null && isMetaData(next)) next = next.getNext(); return next; } /** * @param insn * Instruction to begin from. * * @return Previous non-metadata instruction. * Can be {@code null} for no previous instruction at the start of a method. */ @Nullable public static AbstractInsnNode getPreviousInsn(@Nonnull AbstractInsnNode insn) { AbstractInsnNode prev = insn.getPrevious(); while (prev != null && isMetaData(prev)) prev = prev.getPrevious(); return prev; } /** * @param insn * Instruction to begin from. * * @return Next non-metadata instruction, following {@link Opcodes#GOTO} if found. * Can be {@code null} for no next instruction at the end of a method. */ @Nullable public static AbstractInsnNode getNextFollowGoto(@Nonnull AbstractInsnNode insn) { AbstractInsnNode next = getNextInsn(insn); while (next != null && next.getOpcode() == GOTO) { JumpInsnNode jin = (JumpInsnNode) next; next = getNextInsn(jin.label); } return next; } /** * Primarily used for debugging and passing to {@link BlwUtil#toString(Iterable)}. * * @param insn * Midpoint instruction. * @param back * Steps backwards to take and include in the output. * @param forward * Steps forward to take and include in the output. * * @return List of instructions surrounding the given instruction. */ @Nonnull public static List getSurrounding(@Nonnull AbstractInsnNode insn, int back, int forward) { List list = new ArrayList<>(back + forward + 1); AbstractInsnNode t = insn; for (int i = 0; i < back; i++) { t = getPreviousInsn(t); if (t == null) break; list.addFirst(t); } t = insn; list.add(t); for (int i = 0; i < forward; i++) { t = getNextInsn(t); if (t == null) break; list.add(t); } return list; } /** * @param method * Containing method of the instruction. * @param insn * Instruction to search for. * * @return The {@link TryCatchBlockNode} of a try start-end range containing the given instruction. */ @Nullable public static TryCatchBlockNode getContainingTryBlock(@Nonnull MethodNode method, @Nonnull AbstractInsnNode insn) { for (TryCatchBlockNode block : method.tryCatchBlocks) { AbstractInsnNode i = block.start; while (i != null && i != block.end && i != insn) i = i.getNext(); if (i == insn) return block; } return null; } /** * Check if the given block of instructions has a catch block handler target. *

* When {@code includeFirstInsn=true} this will include match the first instruction of the block if it is * the label outlined by {@link TryCatchBlockNode#handler}. Otherwise, if {@code false} is passed, then the handler * is somewhere in the middle of the block. * * @param method * Containing method to analyze control flow of. * @param block * Some arbitrary list of instructions representing a block of code. * @param includeFirstInsn * {@code true} to count the first instruction of the {@code block} which is assumed to be a * {@link LabelNode} that is a candidate for being a value of {@link TryCatchBlockNode#handler}. * * @return {@code true} when the block contains a label that is a catch block handler. */ public static boolean hasHandlerFlowIntoBlock(@Nonnull MethodNode method, @Nonnull List block, boolean includeFirstInsn) { int start = includeFirstInsn ? 0 : 1; for (TryCatchBlockNode tryCatchBlock : method.tryCatchBlocks) if (block.indexOf(tryCatchBlock.handler) >= start) return true; return false; } /** * Check if the given instruction can throw an exception. * * @param insn * Instruction to check. * * @return {@code true} when the instruction can throw an exception. */ public static boolean canThrow(@Nonnull AbstractInsnNode insn) { int op = insn.getOpcode(); if (op == ATHROW) // Obvious case return true; if (insn instanceof MethodInsnNode) // Method calls can throw. return true; // NullPointerException return op == GETFIELD || op == PUTFIELD || op == ARRAYLENGTH || // NullPointerException, ArrayIndexOutOfBoundsException (op >= IALOAD && op <= AASTORE) || // IllegalMonitorStateException op == MONITORENTER || op == MONITOREXIT || // ArithmeticException op == IDIV || op == IREM || op == LDIV || op == LREM; } /** * Check if the given block of instructions is referenced by explicit control flow instructions. *

* This does not cover the following cases: *

    *
  • Linear control flow where the previous instruction continues normally to the next instruction.
  • *
* This is checks for explicit control flow references such as: *
    *
  • {@link JumpInsnNode}
  • *
  • {@link TableSwitchInsnNode}
  • *
  • {@link LookupSwitchInsnNode}
  • *
* * @param method * Containing method to analyze control flow of. * @param block * Some arbitrary list of instructions representing a block of code. * * @return {@code true} when the method has control flow outside the given block that flows into the given block. * {@code false} when the given block is never explicitly flowed into via control flow instructions. */ public static boolean hasInboundFlowReferences(@Nonnull MethodNode method, @Nonnull List block) { Set labels = Collections.newSetFromMap(new IdentityHashMap<>()); for (AbstractInsnNode insn : block) if (insn.getType() == AbstractInsnNode.LABEL) labels.add((LabelNode) insn); // If the block has no labels, then there cannot be any inbound references. if (labels.isEmpty()) return false; // No control flow instruction should point to this block *at all*. for (AbstractInsnNode insn : method.instructions) { // Skip instructions in the given block. if (block.contains(insn)) continue; // Check for control-flow instructions pointing to a location in the given block. if (insn instanceof JumpInsnNode jump && block.contains(jump.label)) return true; if (insn instanceof TableSwitchInsnNode tswitch) { if (labels.contains(tswitch.dflt)) return true; for (LabelNode label : tswitch.labels) if (labels.contains(label)) return true; } if (insn instanceof LookupSwitchInsnNode lswitch) { if (labels.contains(lswitch.dflt)) return true; for (LabelNode label : lswitch.labels) if (labels.contains(label)) return true; } } return false; } /** * Populate control flow maps for a method. * * @param method * Method to analyze. * @param successorMap * Output successor map. * @param predecessorMap * Output predecessor map. */ @SuppressWarnings("StatementWithEmptyBody") public static void populateFlowMaps(@Nonnull MethodNode method, @Nonnull Int2ObjectMap> successorMap, @Nonnull Int2ObjectMap> predecessorMap) { populateFlowMaps(method, successorMap, predecessorMap, true); } /** * Populate control flow maps for a method. * * @param method * Method to analyze. * @param successorMap * Output successor map. * @param predecessorMap * Output predecessor map. * @param followExceptionFlow * Flag to indicate whether to include exception flow in the maps. * When {@code true}, instructions that can throw exceptions will have their respective catch block handlers included as successors. * When {@code false}, exception flow is ignored and only explicit control flow instructions are considered. */ @SuppressWarnings("StatementWithEmptyBody") public static void populateFlowMaps(@Nonnull MethodNode method, @Nonnull Int2ObjectMap> successorMap, @Nonnull Int2ObjectMap> predecessorMap, boolean followExceptionFlow) { InsnList instructions = method.instructions; int size = instructions.size(); for (int i = 0; i < size; i++) { AbstractInsnNode insn = instructions.get(i); if (insn == null) // Sanity check, can happen if you don't use ASM the way it wants you to. throw new IllegalStateException("You broke the method instruction list"); List ss = new ArrayList<>(); int op = insn.getOpcode(); if (op >= IRETURN && op <= RETURN || op == ATHROW) { // Terminal instructions, no successors. } else if (insn instanceof JumpInsnNode jin) { // Jump instructions have their target and fall-through (excluding goto/jsr) as successors. int target = instructions.indexOf(jin.label); ss.add(target); if (op != GOTO && op != JSR) if (i + 1 < size) ss.add(i + 1); } else if (insn instanceof TableSwitchInsnNode tswitch) { // Switch instructions have all their targets as successors. ss.add(instructions.indexOf(tswitch.dflt)); for (LabelNode label : tswitch.labels) ss.add(instructions.indexOf(label)); } else if (insn instanceof LookupSwitchInsnNode lswitch) { // Same as above. ss.add(instructions.indexOf(lswitch.dflt)); for (LabelNode label : lswitch.labels) ss.add(instructions.indexOf(label)); } else { // All other instructions just flow to the next instruction. if (i + 1 < size) ss.add(i + 1); } // Add exception successors. if (followExceptionFlow && canThrow(insn)) { // TODO: Used to filter if the contained instructions could actually throw the handled type. // IE, a block of 'nop' instructions wouldn't actually throw anything. for (TryCatchBlockNode block : method.tryCatchBlocks) { int start = instructions.indexOf(block.start); int end = instructions.indexOf(block.end); if (start <= i && i < end) ss.add(instructions.indexOf(block.handler)); } } successorMap.put(i, ss); } // Populate predecessor map from successor map. for (int i = 0; i < size; i++) { for (int s : successorMap.getOrDefault(i, emptyList())) predecessorMap.computeIfAbsent(s, _ -> new ArrayList<>()).add(i); } } /** * Compute reachable instructions in a method. * * @param size * Size of {@link InsnList} for a method's instructions. * @param successors * Control flow successor map for the method. See {@link #populateFlowMaps(MethodNode, Int2ObjectMap, Int2ObjectMap)} * * @return BitSet of reachable instructions in the method. */ @Nonnull public static BitSet computeReachable(int size, @Nonnull Int2ObjectMap> successors) { BitSet reachable = new BitSet(size); if (size == 0) return reachable; // Compute reachable instructions. Start at the beginning. Deque unprocessed = new ArrayDeque<>(); reachable.set(0); unprocessed.add(0); while (!unprocessed.isEmpty()) { // Follow successors. int current = unprocessed.poll(); for (int s : successors.getOrDefault(current, List.of())) { if (!reachable.get(s)) { reachable.set(s); unprocessed.add(s); } } } return reachable; } /** * Computes the size of stack items consumed for the given operation of the instruction. *
    *
  • This considers {@code long} and {@code double} types taking two spaces.
  • *
  • This considers {@code dup} like instructions to not "consume" values.
  • *
* * @param insn * Instruction to compute for. * * @return Size of stack consumed. Never negative. * * @see #getSizeProduced(AbstractInsnNode) * @see JVMS 6.5 */ public static int getSizeConsumed(@Nonnull AbstractInsnNode insn) { // Just a note about this consumed and the other produced method, ASM has a giant // array in MethodWriter that has the total delta, but in some cases you want to know // both components that combine into the final delta. It also skips entries that are // not constant, so we would still need some edge-case handling anyways. int op = insn.getOpcode(); int type = insn.getType(); if (type == AbstractInsnNode.MULTIANEWARRAY_INSN) { return ((MultiANewArrayInsnNode) insn).dims; } else if (type == AbstractInsnNode.METHOD_INSN) { MethodInsnNode min = (MethodInsnNode) insn; Type methodType = Type.getMethodType(min.desc); int count = op == INVOKESTATIC ? 0 : 1; for (Type argType : methodType.getArgumentTypes()) { count += argType.getSize(); } return count; } else if (type == AbstractInsnNode.INVOKE_DYNAMIC_INSN) { Type methodType = Type.getMethodType(((InvokeDynamicInsnNode) insn).desc); int count = 0; for (Type argType : methodType.getArgumentTypes()) { count += argType.getSize(); } return count; } else if (type == AbstractInsnNode.FIELD_INSN) { FieldInsnNode fin = (FieldInsnNode) insn; if (op == GETSTATIC) return 0; if (op == GETFIELD) return 1; // owner-value if (op == PUTSTATIC) return Type.getType(fin.desc).getSize(); // value (can be wide) if (op == PUTFIELD) return 1 + Type.getType(fin.desc).getSize(); // owner, value (can be wide) } else if (type == AbstractInsnNode.FRAME || type == AbstractInsnNode.LABEL || type == AbstractInsnNode.LINE) { return 0; } // noinspection EnhancedSwitchMigration switch (op) { // visitInsn case NOP: case ACONST_NULL: case ICONST_M1: case ICONST_0: case ICONST_1: case ICONST_2: case ICONST_3: case ICONST_4: case ICONST_5: case LCONST_0: case LCONST_1: case FCONST_0: case FCONST_1: case FCONST_2: case DCONST_0: case DCONST_1: return 0; // visitIntInsn case BIPUSH: case SIPUSH: return 0; // visitLdcInsn case LDC: return 0; // visitVarInsn case ILOAD: case LLOAD: case FLOAD: case DLOAD: case ALOAD: return 0; // visitInsn case IALOAD: case LALOAD: case FALOAD: case DALOAD: case AALOAD: case BALOAD: case CALOAD: case SALOAD: return 2; // arrayref, index // visitVarInsn case ISTORE: case FSTORE: case ASTORE: return 1; // value case DSTORE: case LSTORE: return 2; // wide-value // visitInsn case IASTORE: case FASTORE: case AASTORE: case BASTORE: case CASTORE: case SASTORE: return 3; // arrayref, index, value case DASTORE: case LASTORE: return 4; // arrayref, index, wide-value case POP: return 1; // value case POP2: return 2; // value x2 or wide-value case DUP: case DUP_X1: case DUP_X2: case DUP2: case DUP2_X1: case DUP2_X2: case SWAP: return 0; // Does not "consume" technically case IADD: case FADD: case ISUB: case FSUB: case IMUL: case FMUL: case IDIV: case FDIV: case IREM: case FREM: case ISHL: case ISHR: case IUSHR: case IAND: case IXOR: case IOR: return 2; // value1, value2 case LUSHR: case LSHR: case LSHL: return 3; // wide-value1, value2 case DREM: case DDIV: case DMUL: case DSUB: case DADD: case LREM: case LDIV: case LMUL: case LSUB: case LADD: case LAND: case LOR: case LXOR: return 4; // wide-value1, wide-value2 case INEG: case FNEG: return 1; // value case DNEG: case LNEG: return 2; // wide-value // visitIincInsn case IINC: return 0; // visitInsn case I2L: case I2F: case I2D: case F2I: case F2L: case F2D: case I2B: case I2C: case I2S: return 1; // value case D2I: case D2L: case D2F: case L2I: case L2F: case L2D: return 2; // wide-value case FCMPL: case FCMPG: return 2; // value1, value2 case LCMP: case DCMPL: case DCMPG: return 4; // wide-value1, wide-value2 // visitJumpInsn case IFEQ: case IFNE: case IFLT: case IFGE: case IFGT: case IFLE: case IFNULL: case IFNONNULL: return 1; // value case IF_ICMPEQ: case IF_ICMPNE: case IF_ICMPLT: case IF_ICMPGE: case IF_ICMPGT: case IF_ICMPLE: case IF_ACMPEQ: case IF_ACMPNE: return 2; // value1, value2 case GOTO: return 0; case JSR: return 0; // visitVarInsn case RET: return 0; // visiTableSwitchInsn/visitLookupSwitch case TABLESWITCH: case LOOKUPSWITCH: return 1; // value // visitInsn case IRETURN: case FRETURN: case ARETURN: return 1; // value case LRETURN: case DRETURN: return 2; // wide-value case RETURN: return 0; // visitTypeInsn case NEW: return 0; // visitIntInsn case NEWARRAY: return 1; // count // visitTypeInsn case ANEWARRAY: return 1; // count // visitInsn case ARRAYLENGTH: return 1; // array case ATHROW: return 1; // exception, but it technically should clear the stack // visitTypeInsn case CHECKCAST: return 0; // instance to verify, not technically consumed but referenced case INSTANCEOF: return 1; // value // visitInsn case MONITORENTER: case MONITOREXIT: return 1; // monitor default: throw new IllegalArgumentException("Unhandled instruction: " + op); } } /** * Computes the size of stack items produced for the given operation of the instruction. * This considers {@code long} and {@code double} types taking two spaces. * * @param insn * Instruction to compute for. * * @return Size of stack produced. Never negative. * * @see #getSizeConsumed(AbstractInsnNode) * @see JVMS 6.5 */ public static int getSizeProduced(AbstractInsnNode insn) { int op = insn.getOpcode(); int type = insn.getType(); if (type == AbstractInsnNode.METHOD_INSN) { Type methodType = Type.getMethodType(((MethodInsnNode) insn).desc); return methodType.getReturnType().getSize(); } else if (type == AbstractInsnNode.INVOKE_DYNAMIC_INSN) { Type methodType = Type.getMethodType(((InvokeDynamicInsnNode) insn).desc); return methodType.getReturnType().getSize(); } else if (type == AbstractInsnNode.FIELD_INSN) { FieldInsnNode fin = (FieldInsnNode) insn; Type fieldtype = Type.getType(fin.desc); if (op == GETSTATIC) return fieldtype.getSize(); // field type can be wide if (op == GETFIELD) return fieldtype.getSize(); // field type can be wide if (op == PUTSTATIC || op == PUTFIELD) return 0; } else if (type == AbstractInsnNode.LDC_INSN) { Object cst = ((LdcInsnNode) insn).cst; return (cst instanceof Double || cst instanceof Long) ? 2 : 1; } else if (type == AbstractInsnNode.FRAME || type == AbstractInsnNode.LABEL || type == AbstractInsnNode.LINE) { return 0; } else if (type == AbstractInsnNode.JUMP_INSN) { return op == JSR ? 1 : 0; } // noinspection EnhancedSwitchMigration switch (op) { // visitInsn case NOP: return 0; case ACONST_NULL: case ICONST_M1: case ICONST_0: case ICONST_1: case ICONST_2: case ICONST_3: case ICONST_4: case ICONST_5: case FCONST_0: case FCONST_1: case FCONST_2: return 1; // value case LCONST_0: case LCONST_1: case DCONST_0: case DCONST_1: return 2; // wide-value // visitIntInsn case BIPUSH: case SIPUSH: return 1; // value // visitVarInsn case ILOAD: case FLOAD: case ALOAD: return 1; // value case LLOAD: case DLOAD: return 2; // wide-value // visitInsn case IALOAD: case FALOAD: case AALOAD: case BALOAD: case CALOAD: case SALOAD: return 1; // value case LALOAD: case DALOAD: return 2; // wide-value // visitVarInsn case ISTORE: case LSTORE: case FSTORE: case DSTORE: case ASTORE: return 0; // visitInsn case IASTORE: case LASTORE: case FASTORE: case DASTORE: case AASTORE: case BASTORE: case CASTORE: case SASTORE: return 0; case POP: case POP2: return 0; case DUP: case DUP_X1: case DUP_X2: return 1; // stack stuff case DUP2: case DUP2_X1: case DUP2_X2: return 2; // stack stuff case SWAP: return 0; // technically does not introduce case IADD: case FADD: case ISUB: case FSUB: case IMUL: case FMUL: case IDIV: case FDIV: case IREM: case FREM: case INEG: case FNEG: case ISHL: case ISHR: case IUSHR: case IAND: case IOR: case IXOR: return 1; // result case LDIV: case LMUL: case LSUB: case LADD: case LREM: case LNEG: case LSHL: case LSHR: case LUSHR: case LAND: case LOR: case LXOR: case DADD: case DSUB: case DMUL: case DDIV: case DREM: case DNEG: return 2; // wide-result // visitIincInsn case IINC: return 0; // visitInsn case I2F: case L2I: case L2F: case F2I: case D2I: case D2F: case I2B: case I2C: case I2S: return 1; // result case I2D: case L2D: case F2D: case F2L: case D2L: case I2L: return 2; // wide-result case LCMP: case FCMPL: case FCMPG: case DCMPL: case DCMPG: return 1; // result // visitVarInsn case RET: return 0; // visiTableSwitchInsn/visitLookupSwitch case TABLESWITCH: case LOOKUPSWITCH: return 0; // visitInsn case IRETURN: case LRETURN: case FRETURN: case DRETURN: case ARETURN: case RETURN: return 0; // visitTypeInsn case NEW: return 1; // uninitialized value // visitIntInsn case NEWARRAY: return 1; // visitTypeInsn case ANEWARRAY: return 1; // visitInsn case ARRAYLENGTH: return 1; case ATHROW: return 0; // visitTypeInsn case CHECKCAST: return 0; // technically does not introduce case INSTANCEOF: return 1; // result // visitInsn case MONITORENTER: return 0; case MONITOREXIT: return 0; // visitMultiANewArrayInsn case MULTIANEWARRAY: return 1; // array default: throw new IllegalArgumentException("Unhandled instruction: " + op); } } } ================================================ FILE: recaf-core/src/main/java/software/coley/recaf/util/BlwUtil.java ================================================ package software.coley.recaf.util; import dev.xdark.blw.asm.internal.Util; import dev.xdark.blw.code.ExtensionOpcodes; import dev.xdark.blw.code.Instruction; import dev.xdark.blw.code.Label; import dev.xdark.blw.code.generic.GenericLabel; import dev.xdark.blw.code.instruction.BranchInstruction; import dev.xdark.blw.code.instruction.ConditionalJumpInstruction; import dev.xdark.blw.code.instruction.ImmediateJumpInstruction; import dev.xdark.blw.code.instruction.LookupSwitchInstruction; import dev.xdark.blw.code.instruction.SimpleInstruction; import dev.xdark.blw.code.instruction.TableSwitchInstruction; import dev.xdark.blw.code.instruction.VarInstruction; import dev.xdark.blw.code.instruction.VariableIncrementInstruction; import dev.xdark.blw.simulation.ExecutionEngines; import jakarta.annotation.Nonnull; import me.darknet.assembler.helper.Variables; import me.darknet.assembler.printer.InstructionPrinter; import me.darknet.assembler.printer.PrintContext; import org.objectweb.asm.Opcodes; import org.objectweb.asm.tree.AbstractInsnNode; import org.objectweb.asm.tree.FieldInsnNode; import org.objectweb.asm.tree.FrameNode; import org.objectweb.asm.tree.IincInsnNode; import org.objectweb.asm.tree.InsnNode; import org.objectweb.asm.tree.IntInsnNode; import org.objectweb.asm.tree.InvokeDynamicInsnNode; import org.objectweb.asm.tree.JumpInsnNode; import org.objectweb.asm.tree.LabelNode; import org.objectweb.asm.tree.LdcInsnNode; import org.objectweb.asm.tree.LineNumberNode; import org.objectweb.asm.tree.LookupSwitchInsnNode; import org.objectweb.asm.tree.MethodInsnNode; import org.objectweb.asm.tree.TableSwitchInsnNode; import org.objectweb.asm.tree.TypeInsnNode; import org.objectweb.asm.tree.VarInsnNode; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.stream.Collectors; /** * Misc blw utilities. * * @author Matt Coley */ public class BlwUtil { /** * @param insn * ASM instruction. * * @return BLW instruction. */ @Nonnull public static Instruction convert(@Nonnull AbstractInsnNode insn) { return switch (insn) { case LdcInsnNode ldc -> Util.wrapLdcInsn(ldc.cst); case MethodInsnNode min -> Util.wrapMethodInsn(min.getOpcode(), min.owner, min.name, min.desc, false); case FieldInsnNode fin -> Util.wrapFieldInsn(fin.getOpcode(), fin.owner, fin.name, fin.desc); case TypeInsnNode tin -> Util.wrapTypeInsn(tin.getOpcode(), tin.desc); case IntInsnNode iin -> Util.wrapIntInsn(iin.getOpcode(), iin.operand); case InsnNode in -> Util.wrapInsn(in.getOpcode()); case InvokeDynamicInsnNode indy -> Util.wrapInvokeDynamicInsn(indy.name, indy.desc, indy.bsm, indy.bsmArgs); case VarInsnNode vin -> new VarInstruction(insn.getOpcode(), vin.var); case IincInsnNode iin -> new VariableIncrementInstruction(iin.var, iin.incr); case JumpInsnNode jin -> { int offset = AsmInsnUtil.indexOf(jin.label); Label label = new GenericLabel(); label.setIndex(offset); yield jin.getOpcode() == Opcodes.GOTO ? new ImmediateJumpInstruction(insn.getOpcode(), label) : new ConditionalJumpInstruction(insn.getOpcode(), label); } case TableSwitchInsnNode tsin -> { int min = tsin.min; Label dflt = new GenericLabel(); dflt.setIndex(AsmInsnUtil.indexOf(tsin.dflt)); List