diff --git a/recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/ZipMarkerProperty.java b/recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/ZipMarkerProperty.java new file mode 100644 index 000000000..32f6a97ad --- /dev/null +++ b/recaf-core/src/main/java/software/coley/recaf/info/properties/builtin/ZipMarkerProperty.java @@ -0,0 +1,60 @@ +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); + } +} diff --git a/recaf-core/src/main/java/software/coley/recaf/services/workspace/io/BasicInfoImporter.java b/recaf-core/src/main/java/software/coley/recaf/services/workspace/io/BasicInfoImporter.java index d673d838b..6a71ae2fe 100644 --- a/recaf-core/src/main/java/software/coley/recaf/services/workspace/io/BasicInfoImporter.java +++ b/recaf-core/src/main/java/software/coley/recaf/services/workspace/io/BasicInfoImporter.java @@ -1,15 +1,17 @@ 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.builder.*; import software.coley.recaf.info.properties.builtin.IllegalClassSuspectProperty; +import software.coley.recaf.info.properties.builtin.ZipMarkerProperty; import software.coley.recaf.util.ByteHeaderUtil; import software.coley.recaf.util.IOUtil; import software.coley.recaf.util.android.AndroidXmlUtil; @@ -59,14 +61,14 @@ public Info readInfo(@Nonnull String name, @Nonnull ByteSource source) throws IO logger.debug("CafeDude patched class: {}", name); try { return new JvmClassInfoBuilder(patched) - .skipValidationChecks(false) - .build(); + .skipValidationChecks(false) + .build(); } catch (Throwable t1) { logger.error("CafeDude patching output is still non-compliant with ASM for file: {}", name); return new FileInfoBuilder<>() - .withRawContent(data) - .withName(name) - .build(); + .withRawContent(data) + .withName(name) + .build(); } } } catch (Throwable t) { @@ -82,6 +84,56 @@ public Info readInfo(@Nonnull String name, @Nonnull ByteSource source) throws IO } // 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) @@ -125,35 +177,7 @@ public Info readInfo(@Nonnull String name, @Nonnull ByteSource source) throws IO .withName(name) .build(); } - - // 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 (ByteHeaderUtil.matchAtAnyOffset(data, ByteHeaderUtil.ZIP)) { - ZipFileInfoBuilder builder = new ZipFileInfoBuilder() - .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(); + return null; } /** diff --git a/recaf-core/src/main/java/software/coley/recaf/services/workspace/io/BasicResourceImporter.java b/recaf-core/src/main/java/software/coley/recaf/services/workspace/io/BasicResourceImporter.java index a1904a161..6da2c2a5b 100644 --- a/recaf-core/src/main/java/software/coley/recaf/services/workspace/io/BasicResourceImporter.java +++ b/recaf-core/src/main/java/software/coley/recaf/services/workspace/io/BasicResourceImporter.java @@ -11,8 +11,15 @@ import software.coley.lljzip.util.ExtraFieldTime; import software.coley.lljzip.util.MemorySegmentUtil; import software.coley.recaf.analytics.logging.Logging; -import software.coley.recaf.info.*; +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.*; import software.coley.recaf.services.Service; import software.coley.recaf.util.IOUtil; @@ -22,17 +29,34 @@ 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.workspace.model.bundle.*; -import software.coley.recaf.workspace.model.resource.*; +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.*; +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.*; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.NavigableMap; +import java.util.Objects; +import java.util.TreeMap; /** * Basic implementation of the resource importer. @@ -105,6 +129,17 @@ private WorkspaceResource handleSingle(@Nonnull WorkspaceFileResourceBuilder bui 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. diff --git a/recaf-core/src/test/java/software/coley/recaf/services/workspace/io/InfoImporterTest.java b/recaf-core/src/test/java/software/coley/recaf/services/workspace/io/InfoImporterTest.java index 953ed9087..5fdc86b6a 100644 --- a/recaf-core/src/test/java/software/coley/recaf/services/workspace/io/InfoImporterTest.java +++ b/recaf-core/src/test/java/software/coley/recaf/services/workspace/io/InfoImporterTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import software.coley.recaf.info.*; +import software.coley.recaf.info.properties.builtin.ZipMarkerProperty; import software.coley.recaf.test.TestClassUtils; import software.coley.recaf.util.ZipCreationUtils; import software.coley.recaf.util.io.ByteSource; @@ -98,12 +99,35 @@ void testImportZip() throws IOException { // However, if we provide various extensions then we can use the file name to infer what kind of ZIP it is. read = importer.readInfo("data.jar", zipSource); - assertTrue(read instanceof JarFileInfo); + assertInstanceOf(JarFileInfo.class, read); read = importer.readInfo("data.war", zipSource); - assertTrue(read instanceof WarFileInfo); + assertInstanceOf(WarFileInfo.class, read); read = importer.readInfo("data.jmod", zipSource); - assertTrue(read instanceof JModFileInfo); + assertInstanceOf(JModFileInfo.class, read); read = importer.readInfo("data.apk", zipSource); - assertTrue(read instanceof ApkFileInfo); + assertInstanceOf(ApkFileInfo.class, read); + } + + /** @see ResourceImporterTest#testImportFileWithExeHeaderAsZipIfZipContentsAreValid() */ + @Test + void testImportFileWithoutZipPrefixHasZipMarkerAssigned() throws IOException { + // Create virtual ZIP with single 'Hello.txt' and suffix the file with a PE header. + byte[] zipFileBytes = ZipCreationUtils.createSingleEntryZip("Hello.txt", "Hello world".getBytes(StandardCharsets.UTF_8)); + byte[] inputBytes = new byte[4096]; + inputBytes[0] = 0x4D; + inputBytes[1] = 0x5A; + System.arraycopy(zipFileBytes, 0, inputBytes, inputBytes.length - zipFileBytes.length, zipFileBytes.length); + ByteSource exeSource = ByteSources.wrap(inputBytes); + + // We should import the info as an executable since the header matches + // but note that it has the ZIP marker in its contents. + Info read = importer.readInfo("example.exe", exeSource); + assertTrue(read.isFile()); + assertTrue(read.asFile().isNativeLibraryFile()); + assertFalse(read.asFile().isZipFile()); + assertEquals(BasicNativeLibraryFileInfo.class, read.getClass()); + + // The ZIP marker should exist. + assertTrue(ZipMarkerProperty.get(read.asFile())); } } \ No newline at end of file diff --git a/recaf-core/src/test/java/software/coley/recaf/services/workspace/io/ResourceImporterTest.java b/recaf-core/src/test/java/software/coley/recaf/services/workspace/io/ResourceImporterTest.java index ff3df08bf..c931e6185 100644 --- a/recaf-core/src/test/java/software/coley/recaf/services/workspace/io/ResourceImporterTest.java +++ b/recaf-core/src/test/java/software/coley/recaf/services/workspace/io/ResourceImporterTest.java @@ -2,12 +2,15 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import software.coley.recaf.info.BasicNativeLibraryFileInfo; 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.properties.builtin.ZipAccessTimeProperty; import software.coley.recaf.info.properties.builtin.ZipCommentProperty; import software.coley.recaf.info.properties.builtin.ZipCreationTimeProperty; +import software.coley.recaf.info.properties.builtin.ZipMarkerProperty; import software.coley.recaf.info.properties.builtin.ZipModificationTimeProperty; import software.coley.recaf.test.TestClassUtils; import software.coley.recaf.test.dummy.HelloWorld; @@ -449,4 +452,21 @@ void testZipProperties() throws IOException { assertEquals(timeModify, ZipModificationTimeProperty.get(fileInfo), "Missing modification time"); assertEquals(timeAccess, ZipAccessTimeProperty.get(fileInfo), "Missing access time"); } + + /** @see InfoImporterTest#testImportFileWithoutZipPrefixHasZipMarkerAssigned() */ + @Test + void testImportFileWithExeHeaderAsZipIfZipContentsAreValid() throws IOException { + // Create virtual ZIP with single 'Hello.txt' and suffix the file with a PE header. + byte[] zipFileBytes = ZipCreationUtils.createSingleEntryZip("Hello.txt", "Hello world".getBytes(StandardCharsets.UTF_8)); + byte[] inputBytes = new byte[4096]; + inputBytes[0] = 0x4D; + inputBytes[1] = 0x5A; + System.arraycopy(zipFileBytes, 0, inputBytes, inputBytes.length - zipFileBytes.length, zipFileBytes.length); + ByteSource exeSource = ByteSources.wrap(inputBytes); + + // When we import the "executable" we should still load the ZIP file contents since it has a ZIP marker + // and is able to be processed as a ZIP archive. + WorkspaceResource resource = importer.importResource(exeSource); + assertTrue(resource.getFileBundle().containsKey("Hello.txt")); + } } \ No newline at end of file