diff --git a/recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/PatchApplier.java b/recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/PatchApplier.java index 4a66c8f2b..1e4526239 100644 --- a/recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/PatchApplier.java +++ b/recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/PatchApplier.java @@ -8,17 +8,21 @@ 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; @@ -58,13 +62,24 @@ public void apply(@Nonnull WorkspacePatch patch, @Nullable Consumer> List tasks = new ArrayList<>(); ErrorDelegate errorConsumerDelegate = new ErrorDelegate(errorConsumer); + for (RemovePath removal : patch.removals()) { + PathNode path = removal.path(); + Info toRemove = path.getValueOfType(Info.class); + Bundle containingBundle = path.getValueOfType(Bundle.class); + if (toRemove != null && containingBundle != null) { + String entryName = toRemove.getName(); + if (containingBundle.remove(entryName) == null) + logger.warn("Could not apply removal for path '{}' - not found in the workspace", entryName); + } + } + JvmAssemblerPipeline jvmAssemblerPipeline = assemblerPipelineManager.getJvmAssemblerPipeline(); for (JvmAssemblerPatch jvmAssemblerPatch : patch.jvmAssemblerPatches()) { // Skip if any errors have been seen. if (errorConsumerDelegate.hasSeenErrors()) return; - ClassPathNode path = jvmAssemblerPatch.path(); + ClassPathNode path = jvmAssemblerPatch.path().withCurrentWorkspaceContent(); JvmClassInfo jvmClass = path.getValue().asJvmClass(); JvmClassBundle jvmBundle = path.getValueOfType(JvmClassBundle.class); if (jvmBundle == null) @@ -104,7 +119,7 @@ public void apply(@Nonnull WorkspacePatch patch, @Nullable Consumer> if (errorConsumerDelegate.hasSeenErrors()) return; - FilePathNode path = filePatch.path(); + FilePathNode path = filePatch.path().withCurrentWorkspaceContent(); TextFileInfo textFile = path.getValue().asTextFile(); FileBundle fileBundle = path.getValueOfType(FileBundle.class); if (fileBundle == null) diff --git a/recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/PatchProvider.java b/recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/PatchProvider.java index 67e792ead..0748ede59 100644 --- a/recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/PatchProvider.java +++ b/recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/PatchProvider.java @@ -6,19 +6,18 @@ import me.darknet.assembler.error.Result; import org.slf4j.Logger; import software.coley.recaf.analytics.logging.Logging; -import software.coley.recaf.info.ClassInfo; -import software.coley.recaf.info.FileInfo; -import software.coley.recaf.info.Info; -import software.coley.recaf.info.JvmClassInfo; +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.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; @@ -109,6 +108,7 @@ public WorkspacePatch deserializePatch(@Nonnull Workspace workspace, @Nonnull St */ @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) -> { @@ -128,18 +128,40 @@ public WorkspacePatch createPatch(@Nonnull Workspace workspace) throws PatchGene String initialDisassemble = initialDisassembleRes.get(); String currentDisassemble = currentDisassembleRes.get(); List assemblerDiffs = StringDiff.diff(initialDisassemble, currentDisassemble); - jvmAssemblerPatches.add(new JvmAssemblerPatch(initialPath, assemblerDiffs)); + if (!assemblerDiffs.isEmpty()) + jvmAssemblerPatches.add(new JvmAssemblerPatch(initialPath, assemblerDiffs)); }; PatchConsumer fileConsumer = (filePath, initial, current) -> { - if (!initial.isTextFile() || !current.isTextFile()) { + 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()); - return; } }; 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); @@ -150,6 +172,7 @@ public WorkspacePatch createPatch(@Nonnull Workspace workspace) throws PatchGene } return new WorkspacePatch(workspace, + Collections.unmodifiableList(removals), Collections.unmodifiableList(jvmAssemblerPatches), Collections.unmodifiableList(textFilePatches)); } diff --git a/recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/PatchSerialization.java b/recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/PatchSerialization.java index 85dda5271..bee4a4330 100644 --- a/recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/PatchSerialization.java +++ b/recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/PatchSerialization.java @@ -5,9 +5,11 @@ 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; @@ -27,6 +29,7 @@ */ 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"; @@ -38,6 +41,8 @@ public class PatchSerialization { 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() {} @@ -54,43 +59,75 @@ 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(); - jw.beginObject(); - 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(); + 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.endArray(); + jw.endObject(); } + jw.endArray(); + } + } - 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(); + 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.endObject(); - } catch (Exception ex) { - throw new IllegalStateException("Failed to create json writer for patch", ex); + jw.endArray(); } + } - return out.toString(); + 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 { @@ -120,10 +157,11 @@ private static void serializeStringDiff(@Nonnull JsonWriter jw, @Nonnull StringD */ @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, jvmAssemblerPatches, textFilePatches); + return new WorkspacePatch(workspace, removals, jvmAssemblerPatches, textFilePatches); try { JsonReader jr = GSON.newJsonReader(new StringReader(patchContents)); jr.beginObject(); @@ -133,15 +171,49 @@ public static WorkspacePatch deserialize(@Nonnull Workspace workspace, @Nonnull jvmAssemblerPatches = deserializeClassJvmAsmDiffs(workspace, jr); } else if (name.equals(KEY_FILE_TEXT_DIFFS)) { textFilePatches = deserializeFileTextDiffs(workspace, jr); + } else if (name.equals(KEY_REMOVALS)) { + removals = deserializeRemovals(workspace, jr); } } jr.endObject(); - return new WorkspacePatch(workspace, jvmAssemblerPatches, textFilePatches); + 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 (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<>(); @@ -182,7 +254,7 @@ private static List deserializeFileTextDiffs(@Nonnull Workspace w while (jr.hasNext()) { String key = jr.nextName(); if (key.equals(KEY_NAME)) - name = key; + name = jr.nextString(); else if (key.equals(KEY_DIFFS)) diffs = deserializeStringDiffs(jr); } diff --git a/recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/model/RemovePath.java b/recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/model/RemovePath.java new file mode 100644 index 000000000..1c49fec2d --- /dev/null +++ b/recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/model/RemovePath.java @@ -0,0 +1,14 @@ +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) {} diff --git a/recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/model/WorkspacePatch.java b/recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/model/WorkspacePatch.java index 6ddb99018..9b5a50e1d 100644 --- a/recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/model/WorkspacePatch.java +++ b/recaf-core/src/main/java/software/coley/recaf/services/workspace/patch/model/WorkspacePatch.java @@ -18,13 +18,16 @@ * @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 modifers" + // - "change this method's modifiers" + // - Can be JVM/Android agnostic @Override public boolean equals(Object o) { diff --git a/recaf-core/src/test/java/software/coley/recaf/services/workspace/patch/PatchingTest.java b/recaf-core/src/test/java/software/coley/recaf/services/workspace/patch/PatchingTest.java index da7556c8c..8afc18cb3 100644 --- a/recaf-core/src/test/java/software/coley/recaf/services/workspace/patch/PatchingTest.java +++ b/recaf-core/src/test/java/software/coley/recaf/services/workspace/patch/PatchingTest.java @@ -3,7 +3,7 @@ import me.darknet.assembler.error.Error; import org.junit.jupiter.api.Test; import org.objectweb.asm.ClassWriter; -import software.coley.recaf.info.JvmClassInfo; +import software.coley.recaf.info.*; import software.coley.recaf.services.workspace.patch.model.WorkspacePatch; import software.coley.recaf.test.TestBase; import software.coley.recaf.test.TestClassUtils; @@ -12,6 +12,9 @@ import software.coley.recaf.util.visitors.MethodPredicate; import software.coley.recaf.workspace.model.Workspace; import software.coley.recaf.workspace.model.bundle.BasicJvmClassBundle; +import software.coley.recaf.workspace.model.bundle.ClassBundle; +import software.coley.recaf.workspace.model.bundle.FileBundle; +import software.coley.recaf.workspace.model.bundle.JvmClassBundle; import java.util.stream.Collectors; @@ -59,7 +62,108 @@ void testClass_methodNoop() throws Throwable { patchApplier.apply(patch, errors -> fail("Errors encountered applying patch: " + errors.stream().map(Error::getMessage).collect(Collectors.joining(", ")))); // Validate the patch was applied - JvmClassInfo patchedClassInfo = classes.get(initialClass.getName()); - assertNotEquals(initialClass, patchedClassInfo, "Class bundle post-patch yielded initial class state"); + JvmClassInfo patchedClassInfo = classes.get(classKey); + assertNotSame(initialClass, patchedClassInfo, "Class bundle post-patch yielded initial class state"); + } + + @Test + void testFile_textDiff() throws PatchGenerationException { + TextFileInfo textFile = new StubFileInfo("foo.txt").withText(""" + one + two + three + """); + TextFileInfo textFileAlt = new StubFileInfo(textFile.getName()).withText(""" + one + five + three + """); + FileBundle fileInfos = TestClassUtils.fromFiles(textFile); + Workspace workspace = TestClassUtils.fromBundle(fileInfos); + + // Modify the file + fileInfos.put(textFileAlt); + + // Build the patch. + WorkspacePatch patch = patchProvider.createPatch(workspace); + + // Assert serialization/deserialization doesn't result in breakage. + String serialized = patchProvider.serializePatch(patch); + WorkspacePatch deserializePatch = patchProvider.deserializePatch(workspace, serialized); + assertEquals(patch, deserializePatch); + + // Undo the change. + String fileKey = textFile.getName(); + fileInfos.decrementHistory(fileKey); + TextFileInfo revertedTextFile = fileInfos.get(fileKey).asTextFile(); + assertEquals(textFile.getText(), revertedTextFile.getText(), "Revert failed"); + + // Apply the patch + patchApplier.apply(patch, errors -> fail("Errors encountered applying patch: " + errors.stream().map(Error::getMessage).collect(Collectors.joining(", ")))); + + // Validate the patch was applied + TextFileInfo patchedTextFile = fileInfos.get(fileKey).asTextFile(); + assertNotEquals(textFile, patchedTextFile, "File bundle post-patch yielded initial file state"); + assertEquals(textFileAlt, patchedTextFile, "File bundle post-patch yielded unexpected state"); + } + + @Test + void testRemove_file() throws Throwable { + StubFileInfo bar = new StubFileInfo("bar"); + FileBundle fileBundle = TestClassUtils.fromFiles(new StubFileInfo("foo"), bar, new StubFileInfo("fizz")); + Workspace workspace = TestClassUtils.fromBundle(fileBundle); + + // Remove 'bar' + fileBundle.remove(bar.getName()); + + // Build the patch. + WorkspacePatch patch = patchProvider.createPatch(workspace); + + // Assert the patch has the removal + assertEquals(1, patch.removals().size(), "Expected 1 file removal"); + + // Assert serialization/deserialization doesn't result in breakage. + String serialized = patchProvider.serializePatch(patch); + WorkspacePatch deserializePatch = patchProvider.deserializePatch(workspace, serialized); + assertEquals(patch, deserializePatch); + + // Undo the change. + fileBundle.put(bar); + + // Apply the patch + patchApplier.apply(patch, errors -> fail("Errors encountered applying patch: " + errors.stream().map(Error::getMessage).collect(Collectors.joining(", ")))); + + // Validate the patch was applied + assertNull(fileBundle.get(bar.getName()), "File bundle post-patch did not remove 'bar'"); + } + + @Test + void testRemove_jvmClass() throws Throwable { + JvmClassInfo bar = new StubClassInfo("bar").asJvmClass(); + JvmClassBundle classBundle = TestClassUtils.fromClasses(new StubClassInfo("foo").asJvmClass(), bar, new StubClassInfo("fizz").asJvmClass()); + Workspace workspace = TestClassUtils.fromBundle(classBundle); + + // Remove 'bar' + classBundle.remove(bar.getName()); + + // Build the patch. + WorkspacePatch patch = patchProvider.createPatch(workspace); + + // Assert the patch has the removal + assertEquals(1, patch.removals().size(), "Expected 1 class removal"); + + // Assert serialization/deserialization doesn't result in breakage. + String serialized = patchProvider.serializePatch(patch); + WorkspacePatch deserializePatch = patchProvider.deserializePatch(workspace, serialized); + assertEquals(patch, deserializePatch); + + // Undo the change. + classBundle.put(bar); + + // Apply the patch + patchApplier.apply(patch, errors -> fail("Errors encountered applying patch: " + errors.stream().map(Error::getMessage).collect(Collectors.joining(", ")))); + + // Validate the patch was applied + assertNull(classBundle.get(bar.getName()), "Class bundle post-patch did not remove 'bar'"); } } \ No newline at end of file