GP-3623 - Extensions - Added an extension-specific class loader; moved ExtensionUtils to Generic

This commit is contained in:
dragonmacher
2023-11-21 11:18:28 -05:00
parent 80d92aa32f
commit 0a520b08bd
30 changed files with 1079 additions and 731 deletions
@@ -31,11 +31,11 @@ import ghidra.framework.data.DomainObjectAdapter;
import ghidra.framework.main.FrontEndTool; import ghidra.framework.main.FrontEndTool;
import ghidra.framework.model.*; import ghidra.framework.model.*;
import ghidra.framework.project.DefaultProjectManager; import ghidra.framework.project.DefaultProjectManager;
import ghidra.framework.project.extensions.ExtensionUtils;
import ghidra.framework.store.LockException; import ghidra.framework.store.LockException;
import ghidra.program.database.ProgramDB; import ghidra.program.database.ProgramDB;
import ghidra.util.*; import ghidra.util.*;
import ghidra.util.exception.UsrException; import ghidra.util.exception.UsrException;
import ghidra.util.extensions.ExtensionUtils;
import ghidra.util.task.TaskLauncher; import ghidra.util.task.TaskLauncher;
/** /**
@@ -257,9 +257,9 @@ public abstract class ProcessorEmulatorTestAdapter extends TestCase implements E
// By default, create test output within a directory at the same level as the // By default, create test output within a directory at the same level as the
// development repositories // development repositories
outputRoot = Application.getApplicationRootDirectory() outputRoot = Application.getApplicationRootDirectory()
.getParentFile() .getParentFile()
.getParentFile() .getParentFile()
.getCanonicalPath(); .getCanonicalPath();
} }
catch (IOException e) { catch (IOException e) {
throw new RuntimeException(e); throw new RuntimeException(e);
@@ -938,7 +938,7 @@ public abstract class ProcessorEmulatorTestAdapter extends TestCase implements E
applicationRootDirectories = Application.getApplicationRootDirectories(); applicationRootDirectories = Application.getApplicationRootDirectories();
ResourceFile myModuleRootDirectory = ResourceFile myModuleRootDirectory =
Application.getModuleContainingClass(getClass().getName()); Application.getModuleContainingClass(getClass());
if (myModuleRootDirectory != null) { if (myModuleRootDirectory != null) {
File myModuleRoot = myModuleRootDirectory.getFile(false); File myModuleRoot = myModuleRootDirectory.getFile(false);
if (myModuleRoot != null) { if (myModuleRoot != null) {
@@ -1672,7 +1672,7 @@ public abstract class ProcessorEmulatorTestAdapter extends TestCase implements E
RegisterValue thumbMode = new RegisterValue(tReg, BigInteger.ONE); RegisterValue thumbMode = new RegisterValue(tReg, BigInteger.ONE);
try { try {
program.getProgramContext() program.getProgramContext()
.setRegisterValue(functionAddr, functionAddr, thumbMode); .setRegisterValue(functionAddr, functionAddr, thumbMode);
} }
catch (ContextChangeException e) { catch (ContextChangeException e) {
throw new AssertException(e); throw new AssertException(e);
@@ -1686,7 +1686,7 @@ public abstract class ProcessorEmulatorTestAdapter extends TestCase implements E
RegisterValue thumbMode = new RegisterValue(isaModeReg, BigInteger.ONE); RegisterValue thumbMode = new RegisterValue(isaModeReg, BigInteger.ONE);
try { try {
program.getProgramContext() program.getProgramContext()
.setRegisterValue(functionAddr, functionAddr, thumbMode); .setRegisterValue(functionAddr, functionAddr, thumbMode);
} }
catch (ContextChangeException e) { catch (ContextChangeException e) {
throw new AssertException(e); throw new AssertException(e);
@@ -1911,7 +1911,7 @@ public abstract class ProcessorEmulatorTestAdapter extends TestCase implements E
if (absoluteGzfFilePath.exists()) { if (absoluteGzfFilePath.exists()) {
program = getGzfProgram(outputDir, gzfCachePath); program = getGzfProgram(outputDir, gzfCachePath);
if (program != null && !MD5Utilities.getMD5Hash(testFile.file) if (program != null && !MD5Utilities.getMD5Hash(testFile.file)
.equals(program.getExecutableMD5())) { .equals(program.getExecutableMD5())) {
// remove obsolete GZF cache file // remove obsolete GZF cache file
env.release(program); env.release(program);
program = null; program = null;
@@ -1936,7 +1936,7 @@ public abstract class ProcessorEmulatorTestAdapter extends TestCase implements E
} }
else { else {
program = env.getGhidraProject() program = env.getGhidraProject()
.importProgram(testFile.file, language, compilerSpec); .importProgram(testFile.file, language, compilerSpec);
} }
program.addConsumer(this); program.addConsumer(this);
env.getGhidraProject().close(program); env.getGhidraProject().close(program);
@@ -1957,8 +1957,8 @@ public abstract class ProcessorEmulatorTestAdapter extends TestCase implements E
if (!program.getLanguageID().equals(language.getLanguageID()) || if (!program.getLanguageID().equals(language.getLanguageID()) ||
!program.getCompilerSpec() !program.getCompilerSpec()
.getCompilerSpecID() .getCompilerSpecID()
.equals(compilerSpec.getCompilerSpecID())) { .equals(compilerSpec.getCompilerSpecID())) {
throw new IOException((usingCachedGZF ? "Cached " : "") + throw new IOException((usingCachedGZF ? "Cached " : "") +
"Program has incorrect language/compiler spec (" + program.getLanguageID() + "Program has incorrect language/compiler spec (" + program.getLanguageID() +
"/" + program.getCompilerSpec().getCompilerSpecID() + "): " + "/" + program.getCompilerSpec().getCompilerSpecID() + "): " +
@@ -2123,7 +2123,7 @@ public abstract class ProcessorEmulatorTestAdapter extends TestCase implements E
testFileDigest.append(nameAndAddr); testFileDigest.append(nameAndAddr);
testFileDigest.append(" (GroupInfo @ "); testFileDigest.append(" (GroupInfo @ ");
testFileDigest testFileDigest
.append(testGroup.controlBlock.getInfoStructureAddress().toString(true)); .append(testGroup.controlBlock.getInfoStructureAddress().toString(true));
testFileDigest.append(")"); testFileDigest.append(")");
if (duplicateTests.contains(testGroup.testGroupName)) { if (duplicateTests.contains(testGroup.testGroupName)) {
testFileDigest.append(" *DUPLICATE*"); testFileDigest.append(" *DUPLICATE*");
@@ -26,11 +26,11 @@ import generic.jar.*;
import ghidra.GhidraApplicationLayout; import ghidra.GhidraApplicationLayout;
import ghidra.GhidraLaunchable; import ghidra.GhidraLaunchable;
import ghidra.framework.*; import ghidra.framework.*;
import ghidra.framework.project.extensions.ExtensionUtils;
import ghidra.util.classfinder.ClassFinder; import ghidra.util.classfinder.ClassFinder;
import ghidra.util.classfinder.ClassSearcher; import ghidra.util.classfinder.ClassSearcher;
import ghidra.util.exception.AssertException; import ghidra.util.exception.AssertException;
import ghidra.util.exception.CancelledException; import ghidra.util.exception.CancelledException;
import ghidra.util.extensions.ExtensionUtils;
import ghidra.util.task.TaskMonitor; import ghidra.util.task.TaskMonitor;
import utilities.util.FileUtilities; import utilities.util.FileUtilities;
import utility.application.ApplicationLayout; import utility.application.ApplicationLayout;
@@ -717,9 +717,6 @@ public class GhidraJarBuilder implements GhidraLaunchable {
jarOut.close(); jarOut.close();
} }
/**
* Outputs an individual file to the jar.
*/
public void addFile(String jarPath, File file, ApplicationModule module) public void addFile(String jarPath, File file, ApplicationModule module)
throws IOException, CancelledException { throws IOException, CancelledException {
if (!file.exists()) { if (!file.exists()) {
@@ -834,7 +831,7 @@ public class GhidraJarBuilder implements GhidraLaunchable {
zipOut.close(); zipOut.close();
} }
/** /*
* Outputs an individual file to the jar. * Outputs an individual file to the jar.
*/ */
public void addFile(String zipPath, File file) throws IOException, CancelledException { public void addFile(String zipPath, File file) throws IOException, CancelledException {
@@ -930,7 +927,7 @@ public class GhidraJarBuilder implements GhidraLaunchable {
System.exit(0); System.exit(0);
} }
/** /*
* Entry point for 'gradle buildGhidraJar'. * Entry point for 'gradle buildGhidraJar'.
*/ */
public static void main(String[] args) throws IOException { public static void main(String[] args) throws IOException {
+1
View File
@@ -1,3 +1,4 @@
MODULE FILE LICENSE: lib/commons-compress-1.21.jar Apache License 2.0
MODULE FILE LICENSE: lib/guava-31.1-jre.jar Apache License 2.0 MODULE FILE LICENSE: lib/guava-31.1-jre.jar Apache License 2.0
MODULE FILE LICENSE: lib/failureaccess-1.0.1.jar Apache License 2.0 MODULE FILE LICENSE: lib/failureaccess-1.0.1.jar Apache License 2.0
MODULE FILE LICENSE: lib/jdom-legacy-1.1.3.jar JDOM License MODULE FILE LICENSE: lib/jdom-legacy-1.1.3.jar JDOM License
+1
View File
@@ -32,6 +32,7 @@ dependencies {
api "org.apache.logging.log4j:log4j-api:2.17.1" api "org.apache.logging.log4j:log4j-api:2.17.1"
api "org.apache.logging.log4j:log4j-core:2.17.1" api "org.apache.logging.log4j:log4j-core:2.17.1"
api "org.apache.commons:commons-collections4:4.1" api "org.apache.commons:commons-collections4:4.1"
api "org.apache.commons:commons-compress:1.21"
api "org.apache.commons:commons-lang3:3.12.0" api "org.apache.commons:commons-lang3:3.12.0"
api "org.apache.commons:commons-text:1.10.0" api "org.apache.commons:commons-text:1.10.0"
api "commons-io:commons-io:2.11.0" api "commons-io:commons-io:2.11.0"
@@ -120,6 +120,8 @@ public class GenericApplicationLayout extends ApplicationLayout {
userTempDir = ApplicationUtilities.getDefaultUserTempDir(applicationProperties); userTempDir = ApplicationUtilities.getDefaultUserTempDir(applicationProperties);
userSettingsDir = ApplicationUtilities.getDefaultUserSettingsDir(applicationProperties, userSettingsDir = ApplicationUtilities.getDefaultUserSettingsDir(applicationProperties,
applicationInstallationDir); applicationInstallationDir);
extensionInstallationDirs = Collections.emptyList();
} }
protected Collection<ResourceFile> getAdditionalApplicationRootDirs( protected Collection<ResourceFile> getAdditionalApplicationRootDirs(
@@ -58,10 +58,43 @@ public class Json extends ToStringStyle {
} }
} }
/**
* A {@link ToStringStyle} inspired by {@link ToStringStyle#JSON_STYLE} that places
* object fields all on one line, with Json style formatting.
*/
public static class JsonWithFlatToStringStyle extends ToStringStyle {
private JsonWithFlatToStringStyle() {
this.setUseClassName(false);
this.setUseIdentityHashCode(false);
this.setContentStart("{ ");
this.setContentEnd(" }");
this.setArrayStart("[");
this.setArrayEnd("]");
this.setFieldSeparator(", ");
this.setFieldNameValueSeparator(": ");
this.setNullText("null");
this.setSummaryObjectStartText("\"<");
this.setSummaryObjectEndText(">\"");
this.setSizeStartText("\"<size=");
this.setSizeEndText(">\"");
}
}
/** /**
* Creates a Json string representation of the given object and all of its fields. To exclude * Creates a Json string representation of the given object and all of its fields. To exclude
* some fields, call {@link #toStringExclude(Object, String...)}. To only include particular * some fields, call {@link #toStringExclude(Object, String...)}. To only include particular
* fields, call {@link #appendToString(StringBuffer, String)}. * fields, call {@link #appendToString(StringBuffer, String)}.
* <p>
* The returned string is formatted for pretty printing using whitespace, such as tabs and
* newlines.
*
* @param o the object * @param o the object
* @return the string * @return the string
*/ */
@@ -69,6 +102,18 @@ public class Json extends ToStringStyle {
return ToStringBuilder.reflectionToString(o, Json.WITH_NEWLINES); return ToStringBuilder.reflectionToString(o, Json.WITH_NEWLINES);
} }
/**
* Creates a Json string representation of the given object and all of its fields.
* <p>
* The returned string is formatted without newlines for better use in logging.
*
* @param o the object
* @return the string
*/
public static String toStringFlat(Object o) {
return ToStringBuilder.reflectionToString(o, new JsonWithFlatToStringStyle());
}
/** /**
* Creates a Json string representation of the given object and the given fields * Creates a Json string representation of the given object and the given fields
* @param o the object * @param o the object
@@ -215,6 +215,10 @@ public class Application {
return app.getModuleForClass(className); return app.getModuleForClass(className);
} }
public static ResourceFile getModuleContainingClass(Class<?> c) {
return app.getModuleForClass(c);
}
private void findJavaSourceDirectories(List<ResourceFile> list, private void findJavaSourceDirectories(List<ResourceFile> list,
ResourceFile moduleRootDirectory) { ResourceFile moduleRootDirectory) {
ResourceFile srcDir = new ResourceFile(moduleRootDirectory, "src"); ResourceFile srcDir = new ResourceFile(moduleRootDirectory, "src");
@@ -254,6 +258,18 @@ public class Application {
} }
private ResourceFile getModuleForClass(String className) { private ResourceFile getModuleForClass(String className) {
try {
Class<?> callersClass = Class.forName(className);
return getModuleForClass(callersClass);
}
catch (ClassNotFoundException e) {
// This can happen when we are being called from a script, which is not in the
// classpath. This file will not have a module anyway.
return null;
}
}
private String toPath(String className) {
// get rid of nested class name(s) if present // get rid of nested class name(s) if present
int dollar = className.indexOf('$'); int dollar = className.indexOf('$');
if (dollar != -1) { if (dollar != -1) {
@@ -261,27 +277,22 @@ public class Application {
} }
String path = className.replace('.', '/'); String path = className.replace('.', '/');
String sourcePath = path + ".java"; return path + ".class";
String classFilePath = path + ".class"; }
private ResourceFile getModuleForClass(Class<?> clazz) {
if (inSingleJarMode()) { if (inSingleJarMode()) {
String classFilePath = toPath(clazz.getName());
GModule gModule = getModuleFromTreeMap(classFilePath); GModule gModule = getModuleFromTreeMap(classFilePath);
return gModule == null ? null : gModule.getModuleRoot(); return gModule == null ? null : gModule.getModuleRoot();
} }
// we're running from a binary installation...so get our jar and go up one // we're running from a binary installation...so get our jar and go up one
Class<?> callersClass; File sourceLocationForClass = SystemUtilities.getSourceLocationForClass(clazz);
try {
callersClass = Class.forName(className);
}
catch (ClassNotFoundException e) {
// This can happen when we are being called from a script, which is not in the
// classpath. This file will not have a module anyway
return null;
}
File sourceLocationForClass = SystemUtilities.getSourceLocationForClass(callersClass);
if (sourceLocationForClass.isDirectory()) { if (sourceLocationForClass.isDirectory()) {
String classFilePath = toPath(clazz.getName());
String sourcePath = classFilePath.replace(".class", ".java");
return findModuleForJavaSource(sourcePath); return findModuleForJavaSource(sourcePath);
} }
@@ -22,9 +22,12 @@ import java.util.*;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import generic.json.Json;
import ghidra.GhidraClassLoader;
import ghidra.util.Msg; import ghidra.util.Msg;
import ghidra.util.SystemUtilities; import ghidra.util.SystemUtilities;
import ghidra.util.exception.CancelledException; import ghidra.util.exception.CancelledException;
import ghidra.util.extensions.*;
import ghidra.util.task.TaskMonitor; import ghidra.util.task.TaskMonitor;
import utility.module.ModuleUtilities; import utility.module.ModuleUtilities;
@@ -34,6 +37,9 @@ import utility.module.ModuleUtilities;
public class ClassFinder { public class ClassFinder {
static final Logger log = LogManager.getLogger(ClassFinder.class); static final Logger log = LogManager.getLogger(ClassFinder.class);
private static final boolean IS_USING_RESTRICTED_EXTENSIONS =
Boolean.getBoolean(GhidraClassLoader.ENABLE_RESTRICTED_EXTENSIONS_PROPERTY);
private static List<Class<?>> FILTER_CLASSES = private static List<Class<?>> FILTER_CLASSES =
Collections.unmodifiableList(Arrays.asList(ExtensionPoint.class)); Collections.unmodifiableList(Arrays.asList(ExtensionPoint.class));
@@ -47,8 +53,10 @@ public class ClassFinder {
private void initialize(List<String> searchPaths, TaskMonitor monitor) private void initialize(List<String> searchPaths, TaskMonitor monitor)
throws CancelledException { throws CancelledException {
Set<String> pathSet = new LinkedHashSet<>(searchPaths); Msg.trace(this,
"Using restricted extension class loader? " + IS_USING_RESTRICTED_EXTENSIONS);
Set<String> pathSet = new LinkedHashSet<>(searchPaths);
Iterator<String> pathIterator = pathSet.iterator(); Iterator<String> pathIterator = pathSet.iterator();
while (pathIterator.hasNext()) { while (pathIterator.hasNext()) {
monitor.checkCancelled(); monitor.checkCancelled();
@@ -110,26 +118,58 @@ public class ClassFinder {
return classList; return classList;
} }
/*package*/ static Class<?> loadExtensionPoint(String path, String fullName) { /**
* If the given class name matches the known extension name patterns, then this method will try
* to load that class using the provided path. Extensions may be loaded using their own
* class loader, depending on the system property
* {@link GhidraClassLoader#ENABLE_RESTRICTED_EXTENSIONS_PROPERTY}.
* <p>
* Examples:
* <pre>
* /foo/bar/baz/file.jar fully.qualified.ClassName
* /foo/bar/baz/bin fully.qualified.ClassName
* </pre>
*
* @param path the jar or dir path
* @param className the fully qualified class name
* @return the class if it is an extension point
*/
/*package*/ static Class<?> loadExtensionPoint(String path, String className) {
if (!ClassSearcher.isExtensionPointName(fullName)) { if (!ClassSearcher.isExtensionPointName(className)) {
return null; return null;
} }
ClassLoader classLoader = ClassSearcher.class.getClassLoader(); ClassLoader classLoader = getClassLoader(path);
try { try {
Class<?> c = Class.forName(fullName, true, classLoader); Class<?> c = Class.forName(className, true, classLoader);
if (isClassOfInterest(c)) { if (isClassOfInterest(c)) {
return c; return c;
} }
} }
catch (Throwable t) { catch (Throwable t) {
processClassLoadError(path, fullName, t); processClassLoadError(path, className, t);
} }
return null; return null;
} }
private static ClassLoader getClassLoader(String path) {
ClassLoader classLoader = ClassSearcher.class.getClassLoader();
if (!IS_USING_RESTRICTED_EXTENSIONS) {
return classLoader; // custom extension class loader is disabled
}
ExtensionDetails extension = ExtensionUtils.getExtension(path);
if (extension != null) {
Msg.trace(ClassFinder.class,
"Installing custom extension class loader for: " + Json.toStringFlat(extension));
classLoader = new ExtensionModuleClassLoader(extension);
}
return classLoader;
}
private static void processClassLoadError(String path, String name, Throwable t) { private static void processClassLoadError(String path, String name, Throwable t) {
if (t instanceof LinkageError) { if (t instanceof LinkageError) {
@@ -26,10 +26,12 @@ import java.util.stream.Collectors;
import javax.swing.event.ChangeListener; import javax.swing.event.ChangeListener;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import generic.jar.ResourceFile; import generic.jar.ResourceFile;
import ghidra.GhidraClassLoader;
import ghidra.framework.Application; import ghidra.framework.Application;
import ghidra.util.Msg; import ghidra.util.Msg;
import ghidra.util.SystemUtilities; import ghidra.util.SystemUtilities;
@@ -277,15 +279,30 @@ public class ClassSearcher {
} }
private static List<String> gatherSearchPaths() { private static List<String> gatherSearchPaths() {
String cp = System.getProperty("java.class.path");
StringTokenizer st = new StringTokenizer(cp, File.pathSeparator); //
// By default all classes are found on the standard classpath. In the default mode, there
// are no values associated with the GhidraClassLoader.CP_EXT property. Alternatively,
// users can enable Extension classpath restriction. In this mode, any Extension module's
// jar files will *not* be on the standard classpath, but instead will be on CP_EXT.
//
List<String> rawPaths = new ArrayList<>(); List<String> rawPaths = new ArrayList<>();
while (st.hasMoreTokens()) { getPropertyPaths(GhidraClassLoader.CP, rawPaths);
rawPaths.add(st.nextToken()); getPropertyPaths(GhidraClassLoader.CP_EXT, rawPaths);
return canonicalizePaths(rawPaths);
}
private static void getPropertyPaths(String property, List<String> results) {
String paths = System.getProperty(property);
Msg.trace(ClassSearcher.class, "Paths in " + property + ": " + paths);
if (StringUtils.isBlank(paths)) {
return;
} }
List<String> canonicalPaths = canonicalizePaths(rawPaths); StringTokenizer st = new StringTokenizer(paths, File.pathSeparator);
return canonicalPaths; while (st.hasMoreTokens()) {
results.add(st.nextToken());
}
} }
private static List<String> canonicalizePaths(Collection<String> paths) { private static List<String> canonicalizePaths(Collection<String> paths) {
@@ -13,10 +13,12 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
package ghidra.framework.project.extensions; package ghidra.util.extensions;
import java.io.File; import java.io.File;
import java.util.List; import java.net.MalformedURLException;
import java.net.URL;
import java.util.*;
import generic.jar.ResourceFile; import generic.jar.ResourceFile;
import generic.json.Json; import generic.json.Json;
@@ -191,16 +193,41 @@ public class ExtensionDetails implements Comparable<ExtensionDetails> {
this.version = version; this.version = version;
} }
/**
* Returns URLs for all jar files living in the {extension dir}/lib directory for an installed
* extension.
*
* @return the URLs
*/
public Set<URL> getLibraries() {
if (!isInstalled()) {
return Collections.emptySet();
}
Set<File> jarFiles = new HashSet<>();
findJarFiles(new File(installDir, "lib"), jarFiles);
Set<URL> paths = new HashSet<>();
for (File jar : jarFiles) {
try {
URL jarUrl = jar.toURI().toURL();
paths.add(jarUrl);
}
catch (MalformedURLException e) {
continue;
}
}
return paths;
}
/** /**
* An extension is known to be installed if it has a valid installation path AND that path * An extension is known to be installed if it has a valid installation path AND that path
* contains a Module.manifest file. Extensions that are {@link #isPendingUninstall()} are * contains a Module.manifest file. Extensions that are {@link #isPendingUninstall()} are
* still on the filesystem, may be in use by the tool, but will be removed upon restart. * still on the filesystem, may be in use by the tool, but will be removed upon restart.
* <p> * <p>
* Note: The module manifest file is a marker that indicates several things; one of which is * Note: The module manifest file is a marker that indicates several things; one of which is
* the installation status of an extension. When a user marks an extension to be uninstalled (by * the installation status of an extension. When a user marks an extension to be uninstalled via
* checking the appropriate checkbox in the {@link ExtensionTableModel}), the only thing * the UI, the only thing that is done is to remove this manifest file, which tells the tool to
* that is done is to remove this manifest file, which tells the {@link ExtensionTableProvider} * remove the entire extension directory on the next launch.
* to remove the entire extension directory on the next launch.
* *
* @return true if the extension is installed. * @return true if the extension is installed.
*/ */
@@ -329,7 +356,7 @@ public class ExtensionDetails implements Comparable<ExtensionDetails> {
public boolean clearMarkForUninstall() { public boolean clearMarkForUninstall() {
if (installDir == null) { if (installDir == null) {
Msg.error(ExtensionUtils.class, Msg.error(this,
"Cannot restore extension; extension installation dir is missing for: " + name); "Cannot restore extension; extension installation dir is missing for: " + name);
return false; // already marked as uninstalled return false; // already marked as uninstalled
} }
@@ -373,4 +400,16 @@ public class ExtensionDetails implements Comparable<ExtensionDetails> {
public String toString() { public String toString() {
return Json.toString(this); return Json.toString(this);
} }
private void findJarFiles(File dir, Set<File> jarFiles) {
File[] files = dir.listFiles();
if (files == null) {
return;
}
for (File f : files) {
if (f.isFile() && f.getName().endsWith(".jar")) {
jarFiles.add(f);
}
}
}
} }
@@ -0,0 +1,45 @@
/* ###
* IP: GHIDRA
*
* 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
*
* http://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.
*/
package ghidra.util.extensions;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.Set;
/**
* A class loader used with Ghidra extensions.
*/
public class ExtensionModuleClassLoader extends URLClassLoader {
private ExtensionDetails extensionDir;
public ExtensionModuleClassLoader(ExtensionDetails extensionDir) {
// It is important that this class use the default GhidraClassLoader as its parent. This
// allows resolution of Ghidra classes from extensions.
super(getURLs(extensionDir), ExtensionModuleClassLoader.class.getClassLoader());
this.extensionDir = extensionDir;
}
private static URL[] getURLs(ExtensionDetails extensionDir) {
Set<URL> jars = extensionDir.getLibraries();
return jars.toArray(URL[]::new);
}
@Override
public String toString() {
return "Extension ClassLoader for " + extensionDir.getName();
}
}
@@ -0,0 +1,195 @@
/* ###
* IP: GHIDRA
*
* 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
*
* http://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.
*/
package ghidra.util.extensions;
import java.io.File;
import java.util.*;
import java.util.Map.Entry;
import java.util.stream.Collectors;
import org.apache.logging.log4j.Logger;
import utilities.util.FileUtilities;
/**
* A collection of all extensions found. This class provides methods processing duplicates and
* managing extensions marked for removal.
*/
public class Extensions {
private Logger log;
private Map<String, List<ExtensionDetails>> extensionsByName = new HashMap<>();
Extensions(Logger log) {
this.log = log;
}
/**
* Returns all extensions matching the given details
* @param e the extension details to match
* @return all matching extensions
*/
public List<ExtensionDetails> getMatchingExtensions(ExtensionDetails e) {
return extensionsByName.computeIfAbsent(e.getName(), name -> List.of());
}
/**
* Adds an extension to this collection of extensions
* @param e the extension
*/
void add(ExtensionDetails e) {
extensionsByName.computeIfAbsent(e.getName(), n -> new ArrayList<>()).add(e);
}
/**
* Returns all installed extensions that are not marked for uninstall
* @return all installed extensions that are not marked for uninstall
*/
Set<ExtensionDetails> getActiveExtensions() {
return extensionsByName.values()
.stream()
.filter(list -> !list.isEmpty())
.map(list -> list.get(0))
.filter(ext -> !ext.isPendingUninstall())
.collect(Collectors.toSet());
}
/**
* Removes any extensions that have already been marked for removal. This should be called
* before any class loading has occurred.
*/
void cleanupExtensionsMarkedForRemoval() {
Set<String> names = new HashSet<>(extensionsByName.keySet());
for (String name : names) {
List<ExtensionDetails> extensions = extensionsByName.get(name);
Iterator<ExtensionDetails> it = extensions.iterator();
while (it.hasNext()) {
ExtensionDetails extension = it.next();
if (!extension.isPendingUninstall()) {
continue;
}
if (!removeExtension(extension)) {
log.error("Error removing extension: " + extension.getInstallPath());
}
it.remove();
}
if (extensions.isEmpty()) {
extensionsByName.remove(name);
}
}
}
private boolean removeExtension(ExtensionDetails extension) {
if (extension == null) {
log.error("Extension to uninstall cannot be null");
return false;
}
File installDir = extension.getInstallDir();
if (installDir == null) {
log.error("Extension installation path is not set; unable to delete files");
return false;
}
if (FileUtilities.deleteDir(installDir)) {
extension.setInstallDir(null);
return true;
}
return false;
}
/**
* Returns all unique extensions (no duplicates) that the application is aware of
* @return the extensions
*/
Set<ExtensionDetails> get() {
return extensionsByName.values()
.stream()
.filter(list -> !list.isEmpty())
.map(list -> list.get(0))
.collect(Collectors.toSet());
}
/**
* Returns a string representation of this collection of extensions
* @return a string representation of this collection of extensions
*/
String getAsString() {
StringBuilder buffy = new StringBuilder();
Set<Entry<String, List<ExtensionDetails>>> entries = extensionsByName.entrySet();
for (Entry<String, List<ExtensionDetails>> entry : entries) {
String name = entry.getKey();
buffy.append("Name: ").append(name);
List<ExtensionDetails> extensions = entry.getValue();
if (extensions.size() == 1) {
buffy.append(" - ").append(extensions.get(0).getInstallDir()).append('\n');
}
else {
for (ExtensionDetails e : extensions) {
buffy.append("\t").append(e.getInstallDir()).append('\n');
}
}
}
if (buffy.isEmpty()) {
return "<no extensions installed>";
}
if (!buffy.isEmpty()) {
// remove trailing newline to keep logging consistent
buffy.deleteCharAt(buffy.length() - 1);
}
return buffy.toString();
}
/**
* Logs any duplicate extensions
*/
void reportDuplicateExtensions() {
Set<Entry<String, List<ExtensionDetails>>> entries = extensionsByName.entrySet();
for (Entry<String, List<ExtensionDetails>> entry : entries) {
List<ExtensionDetails> list = entry.getValue();
if (list.size() == 1) {
continue;
}
reportDuplicateExtensionsWhenLoading(entry.getKey(), list);
}
}
private void reportDuplicateExtensionsWhenLoading(String name,
List<ExtensionDetails> extensions) {
ExtensionDetails loadedExtension = extensions.get(0);
File loadedInstallDir = loadedExtension.getInstallDir();
for (int i = 1; i < extensions.size(); i++) {
ExtensionDetails duplicate = extensions.get(i);
log.info("Duplicate extension found '" + name + "'. Keeping extension from " +
loadedInstallDir + ". Skipping extension found at " +
duplicate.getInstallDir());
}
}
}
-1
View File
@@ -1,2 +1 @@
MODULE FILE LICENSE: lib/commons-compress-1.21.jar Apache License 2.0
MODULE FILE LICENSE: lib/xz-1.9.jar Public Domain MODULE FILE LICENSE: lib/xz-1.9.jar Public Domain
-1
View File
@@ -27,7 +27,6 @@ dependencies {
api project(':FileSystem') api project(':FileSystem')
testImplementation project(path: ':Generic', configuration: 'testArtifacts') testImplementation project(path: ':Generic', configuration: 'testArtifacts')
api "org.apache.commons:commons-compress:1.21"
api "org.tukaani:xz:1.9" api "org.tukaani:xz:1.9"
} }
@@ -153,7 +153,7 @@ public class PluginDescription implements Comparable<PluginDescription> {
*/ */
public String getModuleName() { public String getModuleName() {
if (moduleName == null) { if (moduleName == null) {
ResourceFile moduleDir = Application.getModuleContainingClass(pluginClass.getName()); ResourceFile moduleDir = Application.getModuleContainingClass(pluginClass);
moduleName = (moduleDir == null) ? "<No Module>" : moduleDir.getName(); moduleName = (moduleDir == null) ? "<No Module>" : moduleDir.getName();
} }
@@ -16,6 +16,7 @@
package ghidra.framework.plugintool.util; package ghidra.framework.plugintool.util;
import java.lang.reflect.*; import java.lang.reflect.*;
import java.util.List;
import ghidra.framework.plugintool.*; import ghidra.framework.plugintool.*;
import ghidra.util.Msg; import ghidra.util.Msg;
@@ -76,6 +77,14 @@ public class PluginUtils {
*/ */
public static Class<? extends Plugin> forName(String pluginClassName) throws PluginException { public static Class<? extends Plugin> forName(String pluginClassName) throws PluginException {
try { try {
List<Class<? extends Plugin>> classes = ClassSearcher.getClasses(Plugin.class);
for (Class<? extends Plugin> plug : classes) {
if (plug.getName().equals(pluginClassName)) {
return plug;
}
}
Class<?> tmpClass = Class.forName(pluginClassName); Class<?> tmpClass = Class.forName(pluginClassName);
if (!Plugin.class.isAssignableFrom(tmpClass)) { if (!Plugin.class.isAssignableFrom(tmpClass)) {
throw new PluginException( throw new PluginException(
@@ -84,7 +93,7 @@ public class PluginUtils {
return tmpClass.asSubclass(Plugin.class); return tmpClass.asSubclass(Plugin.class);
} }
catch (ClassNotFoundException e) { catch (ClassNotFoundException e) {
throw new PluginException("Plugin class not found"); throw new PluginException("Plugin class not found: " + pluginClassName);
} }
} }
@@ -23,6 +23,7 @@ import javax.swing.text.SimpleAttributeSet;
import docking.widgets.table.threaded.ThreadedTableModelListener; import docking.widgets.table.threaded.ThreadedTableModelListener;
import generic.theme.GColor; import generic.theme.GColor;
import ghidra.framework.plugintool.dialog.AbstractDetailsPanel; import ghidra.framework.plugintool.dialog.AbstractDetailsPanel;
import ghidra.util.extensions.ExtensionDetails;
/** /**
* Panel that shows information about the selected extension in the {@link ExtensionTablePanel}. This * Panel that shows information about the selected extension in the {@link ExtensionTablePanel}. This
@@ -0,0 +1,276 @@
/* ###
* IP: GHIDRA
*
* 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
*
* http://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.
*/
package ghidra.framework.project.extensions;
import java.io.File;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import docking.widgets.OkDialog;
import docking.widgets.OptionDialog;
import generic.jar.ResourceFile;
import ghidra.framework.Application;
import ghidra.util.Msg;
import ghidra.util.extensions.*;
import ghidra.util.task.TaskLauncher;
import utility.application.ApplicationLayout;
/**
* Utility class for managing Ghidra Extensions.
* <p>
* Extensions are defined as any archive or folder that contains an <code>extension.properties</code>
* file. This properties file can contain the following attributes:
* <ul>
* <li>name (required)</li>
* <li>description</li>
* <li>author</li>
* <li>createdOn (format: MM/dd/yyyy)</li>
* <li>version</li>
* </ul>
*
* <p>
* Extensions may be installed/uninstalled by users at runtime, using the
* {@link ExtensionTableProvider}. Installation consists of unzipping the extension archive to an
* installation folder, currently <code>{ghidra user settings dir}/Extensions</code>. To uninstall,
* the unpacked folder is simply removed.
*/
public class ExtensionInstaller {
private static final Logger log = LogManager.getLogger(ExtensionInstaller.class);
/**
* Installs the given extension file. This can be either an archive (zip) or a directory that
* contains an extension.properties file.
*
* @param file the extension to install
* @return true if the extension was successfully installed
*/
public static boolean install(File file) {
log.trace("Installing extension file " + file);
if (file == null) {
log.error("Install file cannot be null");
return false;
}
ExtensionDetails extension = ExtensionUtils.getExtension(file, false);
if (extension == null) {
Msg.showError(ExtensionInstaller.class, null, "Error Installing Extension",
file.getAbsolutePath() + " does not point to a valid ghidra extension");
return false;
}
Extensions extensions = ExtensionUtils.getAllInstalledExtensions();
if (checkForConflictWithDevelopmentExtension(extension, extensions)) {
return false;
}
if (checkForDuplicateExtensions(extension, extensions)) {
return false;
}
// Verify that the version of the extension is valid for this version of Ghidra. If not,
// just exit without installing.
if (!validateExtensionVersion(extension)) {
return false;
}
AtomicBoolean installed = new AtomicBoolean(false);
TaskLauncher.launchModal("Installing Extension", (monitor) -> {
installed.set(ExtensionUtils.install(extension, file, monitor));
});
boolean success = installed.get();
if (success) {
log.trace("Finished installing " + file);
}
else {
log.trace("Failed to install " + file);
}
return success;
}
/**
* Installs the given extension from its declared archive path
* @param extension the extension
* @return true if successful
*/
public static boolean installExtensionFromArchive(ExtensionDetails extension) {
if (extension == null) {
log.error("Extension to install cannot be null");
return false;
}
String archivePath = extension.getArchivePath();
if (archivePath == null) {
log.error(
"Cannot install from archive; extension is missing archive path");
return false;
}
ApplicationLayout layout = Application.getApplicationLayout();
ResourceFile extInstallDir = layout.getExtensionInstallationDirs().get(0);
String extName = extension.getName();
File extDestinationDir = new ResourceFile(extInstallDir, extName).getFile(false);
File archiveFile = new File(archivePath);
if (install(archiveFile)) {
extension.setInstallDir(new File(extDestinationDir, extName));
return true;
}
return false;
}
/**
* Compares the given extension version to the current Ghidra version. If they are different,
* then the user will be prompted to confirm the installation. This method will return true
* if the versions match or the user has chosen to install anyway.
*
* @param extension the extension
* @return true if the versions match or the user has chosen to install anyway
*/
private static boolean validateExtensionVersion(ExtensionDetails extension) {
String extVersion = extension.getVersion();
if (extVersion == null) {
extVersion = "<no version>";
}
String appVersion = Application.getApplicationVersion();
if (extVersion.equals(appVersion)) {
return true;
}
String message = "Extension version mismatch.\nName: " + extension.getName() +
"Extension version: " + extVersion + ".\nGhidra version: " + appVersion + ".";
int choice = OptionDialog.showOptionDialogWithCancelAsDefaultButton(null,
"Extension Version Mismatch",
message,
"Install Anyway");
if (choice != OptionDialog.OPTION_ONE) {
log.info(removeNewlines(message + " Did not install"));
return false;
}
return true;
}
private static String removeNewlines(String s) {
return s.replaceAll("\n", " ");
}
private static boolean checkForDuplicateExtensions(ExtensionDetails newExtension,
Extensions extensions) {
String name = newExtension.getName();
log.trace("Checking for duplicate extensions for '" + name + "'");
List<ExtensionDetails> matches = extensions.getMatchingExtensions(newExtension);
if (matches.isEmpty()) {
log.trace("No matching extensions installed");
return false;
}
log.trace("Duplicate extensions found by name '" + name + "'");
if (matches.size() > 1) {
reportMultipleDuplicateExtensionsWhenInstalling(newExtension, matches);
return true;
}
ExtensionDetails installedExtension = matches.get(0);
String message =
"Attempting to install an extension matching the name of an existing extension.\n" +
"New extension version: " + newExtension.getVersion() + ".\n" +
"Installed extension version: " + installedExtension.getVersion() + ".\n\n" +
"To install, click 'Remove Existing', restart Ghidra, then install again.";
int choice = OptionDialog.showOptionDialogWithCancelAsDefaultButton(null,
"Duplicate Extension",
message,
"Remove Existing");
String installPath = installedExtension.getInstallPath();
if (choice != OptionDialog.OPTION_ONE) {
log.info(
removeNewlines(
message + " Skipping installation. Original extension still installed: " +
installPath));
return true;
}
//
// At this point the user would like to replace the existing extension. We cannot delete
// the existing extension, as it may be in use; mark it for removal.
//
log.info(
removeNewlines(
message + " Installing new extension. Existing extension will be removed after " +
"restart: " + installPath));
installedExtension.markForUninstall();
return true;
}
private static void reportMultipleDuplicateExtensionsWhenInstalling(ExtensionDetails extension,
List<ExtensionDetails> matches) {
StringBuilder buffy = new StringBuilder();
buffy.append("Found multiple duplicate extensions while trying to install '")
.append(extension.getName())
.append("'\n");
for (ExtensionDetails otherExtension : matches) {
buffy.append("Duplicate: " + otherExtension.getInstallPath()).append('\n');
}
buffy.append("Please close Ghidra and manually remove from these extensions from the " +
"filesystem.");
Msg.showInfo(ExtensionInstaller.class, null, "Duplicate Extensions Found",
buffy.toString());
}
private static boolean checkForConflictWithDevelopmentExtension(ExtensionDetails newExtension,
Extensions extensions) {
String name = newExtension.getName();
log.trace("Checking for duplicate dev mode extensions for '" + name + "'");
List<ExtensionDetails> matches = extensions.getMatchingExtensions(newExtension);
if (matches.isEmpty()) {
log.trace("No matching extensions installed");
return false;
}
for (ExtensionDetails extension : matches) {
if (extension.isInstalledInInstallationFolder()) {
String message = "Attempting to install an extension that conflicts with an " +
"extension located in the Ghidra installation folder.\nYou must manually " +
"remove the existing extension to install the new extension.\nExisting " +
"extension: " + extension.getInstallDir();
log.trace(removeNewlines(message));
OkDialog.showError("Duplicate Extensions Found", message);
return true;
}
}
return false;
}
}
@@ -27,6 +27,8 @@ import ghidra.framework.plugintool.ServiceProvider;
import ghidra.util.Msg; import ghidra.util.Msg;
import ghidra.util.datastruct.Accumulator; import ghidra.util.datastruct.Accumulator;
import ghidra.util.exception.CancelledException; import ghidra.util.exception.CancelledException;
import ghidra.util.extensions.ExtensionDetails;
import ghidra.util.extensions.ExtensionUtils;
import ghidra.util.table.column.AbstractGColumnRenderer; import ghidra.util.table.column.AbstractGColumnRenderer;
import ghidra.util.table.column.GColumnRenderer; import ghidra.util.table.column.GColumnRenderer;
import ghidra.util.task.TaskMonitor; import ghidra.util.task.TaskMonitor;
@@ -155,7 +157,7 @@ class ExtensionTableModel extends ThreadedTableModel<ExtensionDetails, Object> {
// into this state is by clicking an extension that was discovered in the 'extension // into this state is by clicking an extension that was discovered in the 'extension
// archives folder' // archives folder'
if (extension.isFromArchive()) { if (extension.isFromArchive()) {
if (ExtensionUtils.installExtensionFromArchive(extension)) { if (ExtensionInstaller.installExtensionFromArchive(extension)) {
refreshTable(); refreshTable();
} }
return; return;
@@ -192,6 +194,7 @@ class ExtensionTableModel extends ThreadedTableModel<ExtensionDetails, Object> {
return; return;
} }
ExtensionUtils.reload();
Set<ExtensionDetails> archived = ExtensionUtils.getArchiveExtensions(); Set<ExtensionDetails> archived = ExtensionUtils.getArchiveExtensions();
Set<ExtensionDetails> installed = ExtensionUtils.getInstalledExtensions(); Set<ExtensionDetails> installed = ExtensionUtils.getInstalledExtensions();
@@ -27,6 +27,7 @@ import docking.widgets.table.*;
import ghidra.app.util.GenericHelpTopics; import ghidra.app.util.GenericHelpTopics;
import ghidra.framework.plugintool.PluginTool; import ghidra.framework.plugintool.PluginTool;
import ghidra.util.HelpLocation; import ghidra.util.HelpLocation;
import ghidra.util.extensions.ExtensionDetails;
import help.Help; import help.Help;
import help.HelpService; import help.HelpService;
@@ -33,6 +33,7 @@ import ghidra.framework.Application;
import ghidra.framework.plugintool.PluginTool; import ghidra.framework.plugintool.PluginTool;
import ghidra.util.HelpLocation; import ghidra.util.HelpLocation;
import ghidra.util.Msg; import ghidra.util.Msg;
import ghidra.util.extensions.ExtensionUtils;
import ghidra.util.filechooser.GhidraFileChooserModel; import ghidra.util.filechooser.GhidraFileChooserModel;
import ghidra.util.filechooser.GhidraFileFilter; import ghidra.util.filechooser.GhidraFileFilter;
import resources.Icons; import resources.Icons;
@@ -105,7 +106,7 @@ public class ExtensionTableProvider extends DialogComponentProvider {
super.dialogClosed(); super.dialogClosed();
if (extensionTablePanel.getTableModel().hasModelChanged() || requireRestart) { if (extensionTablePanel.getTableModel().hasModelChanged() || requireRestart) {
Msg.showInfo(this, getComponent(), "Extensions Changed!", Msg.showInfo(this, null, "Extensions Changed!",
"Please restart Ghidra for extension changes to take effect."); "Please restart Ghidra for extension changes to take effect.");
} }
} }
@@ -176,7 +177,7 @@ public class ExtensionTableProvider extends DialogComponentProvider {
continue; continue;
} }
boolean success = ExtensionUtils.install(file); boolean success = ExtensionInstaller.install(file);
didInstall |= success; didInstall |= success;
} }
return didInstall; return didInstall;
@@ -16,7 +16,6 @@
package ghidra.framework.project.tool; package ghidra.framework.project.tool;
import java.io.File; import java.io.File;
import java.net.MalformedURLException;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.*; import java.util.*;
@@ -29,10 +28,10 @@ import generic.json.Json;
import ghidra.framework.plugintool.*; import ghidra.framework.plugintool.*;
import ghidra.framework.plugintool.dialog.PluginInstallerDialog; import ghidra.framework.plugintool.dialog.PluginInstallerDialog;
import ghidra.framework.plugintool.util.PluginDescription; import ghidra.framework.plugintool.util.PluginDescription;
import ghidra.framework.project.extensions.ExtensionDetails;
import ghidra.framework.project.extensions.ExtensionUtils;
import ghidra.util.NumericUtilities; import ghidra.util.NumericUtilities;
import ghidra.util.classfinder.ClassSearcher; import ghidra.util.classfinder.ClassSearcher;
import ghidra.util.extensions.ExtensionDetails;
import ghidra.util.extensions.ExtensionUtils;
import ghidra.util.xml.XmlUtilities; import ghidra.util.xml.XmlUtilities;
import utilities.util.FileUtilities; import utilities.util.FileUtilities;
@@ -191,12 +190,7 @@ class ExtensionManager {
Set<PluginPath> pluginPaths = getPluginPaths(); Set<PluginPath> pluginPaths = getPluginPaths();
Set<Class<?>> extensionPlugins = new HashSet<>(); Set<Class<?>> extensionPlugins = new HashSet<>();
for (ExtensionDetails extension : extensions) { for (ExtensionDetails extension : extensions) {
File installDir = extension.getInstallDir(); Set<Class<?>> classes = findPluginsLoadedFromExtension(extension, pluginPaths);
if (installDir == null) {
continue;
}
Set<Class<?>> classes = findPluginsLoadedFromExtension(installDir, pluginPaths);
extensionPlugins.addAll(classes); extensionPlugins.addAll(classes);
} }
@@ -219,28 +213,31 @@ class ExtensionManager {
* classpath. For each class, the original resource file is compared against the * classpath. For each class, the original resource file is compared against the
* given extension folder and the jar files for that extension. * given extension folder and the jar files for that extension.
* *
* @param dir the directory to search, or a jar file * @param extension the extension from which to find plugins
* @param pluginPaths all loaded plugin paths * @param pluginPaths all loaded plugin paths
* @return list of {@link Plugin} classes, or empty list if none found * @return list of {@link Plugin} classes, or empty list if none found
*/ */
private static Set<Class<?>> findPluginsLoadedFromExtension(File dir, private static Set<Class<?>> findPluginsLoadedFromExtension(ExtensionDetails extension,
Set<PluginPath> pluginPaths) { Set<PluginPath> pluginPaths) {
Set<Class<?>> result = new HashSet<>(); if (!extension.isInstalled()) {
return Collections.emptySet();
}
// Find any jar files in the directory provided // Find any jar files in the directory provided
Set<String> jarPaths = getJarPaths(dir); Set<URL> jarPaths = extension.getLibraries();
// Now get all Plugin.class file paths and see if any of them were loaded from one of the // Now get all Plugin.class file paths and see if any of them were loaded from one of the
// extension the given extension directory // extension the given extension directory
Set<Class<?>> result = new HashSet<>();
for (PluginPath pluginPath : pluginPaths) { for (PluginPath pluginPath : pluginPaths) {
if (pluginPath.isFrom(dir)) { if (pluginPath.isFrom(extension.getInstallDir())) {
result.add(pluginPath.getPluginClass()); result.add(pluginPath.getPluginClass());
continue; continue;
} }
for (String jarPath : jarPaths) { for (URL jarUrl : jarPaths) {
if (pluginPath.isFrom(jarPath)) { if (pluginPath.isFrom(jarUrl)) {
result.add(pluginPath.getPluginClass()); result.add(pluginPath.getPluginClass());
} }
} }
@@ -248,45 +245,6 @@ class ExtensionManager {
return result; return result;
} }
private static Set<String> getJarPaths(File dir) {
Set<File> jarFiles = new HashSet<>();
findJarFiles(dir, jarFiles);
Set<String> paths = new HashSet<>();
for (File jar : jarFiles) {
try {
URL jarUrl = jar.toURI().toURL();
paths.add(jarUrl.getPath());
}
catch (MalformedURLException e) {
continue;
}
}
return paths;
}
/**
* Populates the given list with all discovered jar files found in the given directory and
* its subdirectories.
*
* @param dir the directory to search
* @param jarFiles list of found jar files
*/
private static void findJarFiles(File dir, Set<File> jarFiles) {
File[] files = dir.listFiles();
if (files == null) {
return;
}
for (File f : files) {
if (f.isDirectory()) {
findJarFiles(f, jarFiles);
}
if (f.isFile() && f.getName().endsWith(".jar")) {
jarFiles.add(f);
}
}
}
private static class PluginPath { private static class PluginPath {
private Class<? extends Plugin> pluginClass; private Class<? extends Plugin> pluginClass;
private String pluginLocation; private String pluginLocation;
@@ -304,7 +262,8 @@ class ExtensionManager {
return FileUtilities.isPathContainedWithin(dir, pluginFile); return FileUtilities.isPathContainedWithin(dir, pluginFile);
} }
boolean isFrom(String jarPath) { boolean isFrom(URL jarUrl) {
String jarPath = jarUrl.getPath();
return pluginLocation.contains(jarPath); return pluginLocation.contains(jarPath);
} }
@@ -31,15 +31,17 @@ import docking.DialogComponentProvider;
import docking.test.AbstractDockingTest; import docking.test.AbstractDockingTest;
import generic.jar.ResourceFile; import generic.jar.ResourceFile;
import ghidra.framework.Application; import ghidra.framework.Application;
import ghidra.util.extensions.ExtensionDetails;
import ghidra.util.extensions.ExtensionUtils;
import utilities.util.FileUtilities; import utilities.util.FileUtilities;
import utility.application.ApplicationLayout; import utility.application.ApplicationLayout;
import utility.function.ExceptionalCallback; import utility.function.ExceptionalCallback;
import utility.module.ModuleUtilities; import utility.module.ModuleUtilities;
/** /**
* Tests for the {@link ExtensionUtils} class. * Tests for the {@link ExtensionInstaller} class.
*/ */
public class ExtensionUtilsTest extends AbstractDockingTest { public class ExtensionInstallerTest extends AbstractDockingTest {
private static final String BUILD_FOLDER_NAME = "TestExtensionParentDir"; private static final String BUILD_FOLDER_NAME = "TestExtensionParentDir";
private static final String TEST_EXT_NAME = "test"; private static final String TEST_EXT_NAME = "test";
@@ -87,7 +89,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
// Create an extension and install it. // Create an extension and install it.
File file = createExtensionZip(TEST_EXT_NAME); File file = createExtensionZip(TEST_EXT_NAME);
ExtensionUtils.install(file); ExtensionInstaller.install(file);
// Verify there is something in the installation directory and it has the correct name // Verify there is something in the installation directory and it has the correct name
checkExtensionInstalledInFilesystem(TEST_EXT_NAME); checkExtensionInstalledInFilesystem(TEST_EXT_NAME);
@@ -101,7 +103,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
// Create an extension and install it. // Create an extension and install it.
File file = createExtensionFolderInArchiveDir(); File file = createExtensionFolderInArchiveDir();
ExtensionUtils.install(file); ExtensionInstaller.install(file);
// Verify the extension is in the install folder and has the correct name // Verify the extension is in the install folder and has the correct name
checkExtensionInstalledInFilesystem(TEST_EXT_NAME); checkExtensionInstalledInFilesystem(TEST_EXT_NAME);
@@ -142,10 +144,9 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
@Test @Test
public void testBadInputs() throws Exception { public void testBadInputs() throws Exception {
errorsExpected(() -> { errorsExpected(() -> {
assertFalse(ExtensionUtils.isExtension(null)); assertFalse(ExtensionInstaller.install(new File("this/file/does/not/exist")));
assertFalse(ExtensionUtils.install(new File("this/file/does/not/exist"))); assertFalse(ExtensionInstaller.install(null));
assertFalse(ExtensionUtils.install(null)); assertFalse(ExtensionInstaller.installExtensionFromArchive(null));
assertFalse(ExtensionUtils.installExtensionFromArchive(null));
}); });
} }
@@ -156,7 +157,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
extension.setArchivePath(zipFile.getAbsolutePath()); extension.setArchivePath(zipFile.getAbsolutePath());
String ghidraVersion = Application.getApplicationVersion(); String ghidraVersion = Application.getApplicationVersion();
extension.setVersion(ghidraVersion); extension.setVersion(ghidraVersion);
assertTrue(ExtensionUtils.installExtensionFromArchive(extension)); assertTrue(ExtensionInstaller.installExtensionFromArchive(extension));
} }
@Test @Test
@@ -168,7 +169,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
AtomicBoolean didInstall = new AtomicBoolean(); AtomicBoolean didInstall = new AtomicBoolean();
runSwingLater(() -> { runSwingLater(() -> {
didInstall.set(ExtensionUtils.installExtensionFromArchive(extension)); didInstall.set(ExtensionInstaller.installExtensionFromArchive(extension));
}); });
DialogComponentProvider confirmDialog = DialogComponentProvider confirmDialog =
@@ -187,7 +188,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
AtomicBoolean didInstall = new AtomicBoolean(); AtomicBoolean didInstall = new AtomicBoolean();
runSwingLater(() -> { runSwingLater(() -> {
didInstall.set(ExtensionUtils.installExtensionFromArchive(extension)); didInstall.set(ExtensionInstaller.installExtensionFromArchive(extension));
}); });
DialogComponentProvider confirmDialog = DialogComponentProvider confirmDialog =
@@ -207,7 +208,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
AtomicBoolean didInstall = new AtomicBoolean(); AtomicBoolean didInstall = new AtomicBoolean();
runSwingLater(() -> { runSwingLater(() -> {
didInstall.set(ExtensionUtils.installExtensionFromArchive(extension)); didInstall.set(ExtensionInstaller.installExtensionFromArchive(extension));
}); });
DialogComponentProvider confirmDialog = DialogComponentProvider confirmDialog =
@@ -221,7 +222,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
public void testMarkForUninstall_ClearMark() throws Exception { public void testMarkForUninstall_ClearMark() throws Exception {
File externalFolder = createExternalExtensionInFolder(); File externalFolder = createExternalExtensionInFolder();
assertTrue(ExtensionUtils.install(externalFolder)); assertTrue(ExtensionInstaller.install(externalFolder));
ExtensionDetails extension = assertExtensionInstalled(TEST_EXT_NAME); ExtensionDetails extension = assertExtensionInstalled(TEST_EXT_NAME);
@@ -238,7 +239,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
public void testCleanupUninstalledExtions_WithExtensionMarkedForUninstall() throws Exception { public void testCleanupUninstalledExtions_WithExtensionMarkedForUninstall() throws Exception {
File externalFolder = createExternalExtensionInFolder(); File externalFolder = createExternalExtensionInFolder();
assertTrue(ExtensionUtils.install(externalFolder)); assertTrue(ExtensionInstaller.install(externalFolder));
ExtensionDetails extension = assertExtensionInstalled(TEST_EXT_NAME); ExtensionDetails extension = assertExtensionInstalled(TEST_EXT_NAME);
@@ -255,8 +256,8 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
public void testCleanupUninstalledExtions_SomeExtensionMarkedForUninstall() throws Exception { public void testCleanupUninstalledExtions_SomeExtensionMarkedForUninstall() throws Exception {
List<File> extensionFolders = createTwoExternalExtensionsInFolder(); List<File> extensionFolders = createTwoExternalExtensionsInFolder();
assertTrue(ExtensionUtils.install(extensionFolders.get(0))); assertTrue(ExtensionInstaller.install(extensionFolders.get(0)));
assertTrue(ExtensionUtils.install(extensionFolders.get(1))); assertTrue(ExtensionInstaller.install(extensionFolders.get(1)));
Set<ExtensionDetails> extensions = ExtensionUtils.getInstalledExtensions(); Set<ExtensionDetails> extensions = ExtensionUtils.getInstalledExtensions();
assertEquals(extensions.size(), 2); assertEquals(extensions.size(), 2);
@@ -279,7 +280,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
public void testCleanupUninstalledExtions_NoExtensionsMarkedForUninstall() throws Exception { public void testCleanupUninstalledExtions_NoExtensionsMarkedForUninstall() throws Exception {
File externalFolder = createExternalExtensionInFolder(); File externalFolder = createExternalExtensionInFolder();
assertTrue(ExtensionUtils.install(externalFolder)); assertTrue(ExtensionInstaller.install(externalFolder));
assertExtensionInstalled(TEST_EXT_NAME); assertExtensionInstalled(TEST_EXT_NAME);
// This should not uninstall any extensions // This should not uninstall any extensions
@@ -299,7 +300,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
String appVersion = Application.getApplicationVersion(); String appVersion = Application.getApplicationVersion();
File extensionFolder = File extensionFolder =
doCreateExternalExtensionInFolder(buildFolder, TEST_EXT_NAME, appVersion); doCreateExternalExtensionInFolder(buildFolder, TEST_EXT_NAME, appVersion);
assertTrue(ExtensionUtils.install(extensionFolder)); assertTrue(ExtensionInstaller.install(extensionFolder));
Set<ExtensionDetails> extensions = ExtensionUtils.getInstalledExtensions(); Set<ExtensionDetails> extensions = ExtensionUtils.getInstalledExtensions();
assertEquals(extensions.size(), 1); assertEquals(extensions.size(), 1);
@@ -313,7 +314,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
AtomicBoolean didInstall = new AtomicBoolean(); AtomicBoolean didInstall = new AtomicBoolean();
runSwingLater(() -> { runSwingLater(() -> {
didInstall.set(ExtensionUtils.install(extensionFolder2)); didInstall.set(ExtensionInstaller.install(extensionFolder2));
}); });
DialogComponentProvider confirmDialog = DialogComponentProvider confirmDialog =
@@ -329,7 +330,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
checkCleanInstall(); checkCleanInstall();
runSwingLater(() -> { runSwingLater(() -> {
didInstall.set(ExtensionUtils.install(extensionFolder2)); didInstall.set(ExtensionInstaller.install(extensionFolder2));
}); });
// no longer an installed extension conflict; now we have a version mismatch // no longer an installed extension conflict; now we have a version mismatch
@@ -349,7 +350,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
String appVersion = Application.getApplicationVersion(); String appVersion = Application.getApplicationVersion();
File extensionFolder = File extensionFolder =
doCreateExternalExtensionInFolder(buildFolder, TEST_EXT_NAME, appVersion); doCreateExternalExtensionInFolder(buildFolder, TEST_EXT_NAME, appVersion);
assertTrue(ExtensionUtils.install(extensionFolder)); assertTrue(ExtensionInstaller.install(extensionFolder));
// create another extension Foo v2 // create another extension Foo v2
File buildFolder2 = createTempDirectory("TestExtensionParentDir2"); File buildFolder2 = createTempDirectory("TestExtensionParentDir2");
@@ -359,7 +360,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
AtomicBoolean didInstall = new AtomicBoolean(); AtomicBoolean didInstall = new AtomicBoolean();
runSwingLater(() -> { runSwingLater(() -> {
didInstall.set(ExtensionUtils.install(extensionFolder2)); didInstall.set(ExtensionInstaller.install(extensionFolder2));
}); });
DialogComponentProvider confirmDialog = DialogComponentProvider confirmDialog =
@@ -379,7 +380,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
String appVersion = Application.getApplicationVersion(); String appVersion = Application.getApplicationVersion();
File extensionFolder = File extensionFolder =
doCreateExternalExtensionInFolder(buildFolder, TEST_EXT_NAME, appVersion); doCreateExternalExtensionInFolder(buildFolder, TEST_EXT_NAME, appVersion);
assertTrue(ExtensionUtils.install(extensionFolder)); assertTrue(ExtensionInstaller.install(extensionFolder));
Set<ExtensionDetails> extensions = ExtensionUtils.getInstalledExtensions(); Set<ExtensionDetails> extensions = ExtensionUtils.getInstalledExtensions();
assertEquals(extensions.size(), 1); assertEquals(extensions.size(), 1);
@@ -393,7 +394,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
AtomicBoolean didInstall = new AtomicBoolean(); AtomicBoolean didInstall = new AtomicBoolean();
runSwingLater(() -> { runSwingLater(() -> {
didInstall.set(ExtensionUtils.install(extensionFolder2)); didInstall.set(ExtensionInstaller.install(extensionFolder2));
}); });
DialogComponentProvider confirmDialog = DialogComponentProvider confirmDialog =
@@ -409,7 +410,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
checkCleanInstall(); checkCleanInstall();
runSwingLater(() -> { runSwingLater(() -> {
didInstall.set(ExtensionUtils.install(extensionFolder2)); didInstall.set(ExtensionInstaller.install(extensionFolder2));
}); });
waitFor(didInstall); waitFor(didInstall);
@@ -437,7 +438,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
errorsExpected(() -> { errorsExpected(() -> {
File zipFile = createZipWithMultipleExtensions(); File zipFile = createZipWithMultipleExtensions();
assertFalse(ExtensionUtils.install(zipFile)); assertFalse(ExtensionInstaller.install(zipFile));
}); });
} }
@@ -452,7 +453,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
String nameProperty = "ExtensionNamedFoo"; String nameProperty = "ExtensionNamedFoo";
File externalFolder = createExtensionWithMismatchingNamePropertyString(nameProperty); File externalFolder = createExtensionWithMismatchingNamePropertyString(nameProperty);
assertTrue(ExtensionUtils.install(externalFolder)); assertTrue(ExtensionInstaller.install(externalFolder));
ExtensionDetails extension = assertExtensionInstalled(nameProperty); ExtensionDetails extension = assertExtensionInstalled(nameProperty);
@@ -31,8 +31,23 @@ import ghidra.util.Msg;
* *
*/ */
public class GhidraClassLoader extends URLClassLoader { public class GhidraClassLoader extends URLClassLoader {
private static final String CP = "java.class.path"; /**
* When 'true', this property will trigger the system to put each Extension module's lib jar
* files into the {@link #CP_EXT} property.
*/
public static final String ENABLE_RESTRICTED_EXTENSIONS_PROPERTY =
"ghidra.extensions.classpath.restricted";
/**
* The classpath system property: {@code java.class.path}
*/
public static final String CP = "java.class.path";
/**
* The extensions classpath system property: {@code java.class.path.ext}
*/
public static final String CP_EXT = "java.class.path.ext";
/** /**
* This one-argument constructor is required for the JVM to successfully use this class loader * This one-argument constructor is required for the JVM to successfully use this class loader
@@ -45,7 +60,7 @@ public class GhidraClassLoader extends URLClassLoader {
} }
@Override @Override
public void addURL(URL url) { public void addURL(URL url) {
super.addURL(url); super.addURL(url);
try { try {
System.setProperty(CP, System.setProperty(CP,
@@ -145,17 +145,36 @@ public class GhidraLauncher {
// First add Eclipse's module "bin" paths. If we didn't find any, assume Ghidra was // First add Eclipse's module "bin" paths. If we didn't find any, assume Ghidra was
// compiled with Gradle, and add the module jars Gradle built. // compiled with Gradle, and add the module jars Gradle built.
addModuleBinPaths(classpathList, modules); addModuleBinPaths(classpathList, modules);
if (classpathList.isEmpty()) { boolean gradleDevMode = classpathList.isEmpty();
if (gradleDevMode) {
// Add the module jars Gradle built.
// Note: this finds Extensions' jar files so there is no need to to call
// addExtensionJarPaths()
addModuleJarPaths(classpathList, modules); addModuleJarPaths(classpathList, modules);
} }
else { /* Eclipse dev mode */
// Support loading pre-built, jar-based, non-repo extensions in Eclipse dev mode
addExtensionJarPaths(classpathList, modules, layout);
}
addExtensionJarPaths(classpathList, modules, layout); // In development mode, jars do not live in module directories. Instead, each jar lives
// in an external, non-repo location, which is listed in build/libraryDependencies.txt.
addExternalJarPaths(classpathList, layout.getApplicationRootDirs()); addExternalJarPaths(classpathList, layout.getApplicationRootDirs());
} }
else { else {
addPatchPaths(classpathList, layout.getPatchDir()); addPatchPaths(classpathList, layout.getPatchDir());
addModuleJarPaths(classpathList, modules); addModuleJarPaths(classpathList, modules);
} }
//
// The framework may choose to handle extension class loading separately from all other
// class loading. In that case, we will separate the extension jar files from standard
// module jar files.
//
// (If the custom extension class loading is disabled, then the extensions will be put onto
// the standard classpath.)
setExtensionJarPaths(modules, layout, classpathList);
classpathList = orderClasspath(classpathList, modules); classpathList = orderClasspath(classpathList, modules);
return classpathList; return classpathList;
} }
@@ -202,6 +221,30 @@ public class GhidraLauncher {
dirs.forEach(d -> pathList.addAll(findJarsInDir(d))); dirs.forEach(d -> pathList.addAll(findJarsInDir(d)));
} }
/**
* Initializes the Extension classpath system property, unless disabled.
* @param modules the known modules
* @param layout the application layout
* @param classpathList the standard classpath elements
*/
private static void setExtensionJarPaths(Map<String, GModule> modules,
GhidraApplicationLayout layout, List<String> classpathList) {
if (!Boolean.getBoolean(GhidraClassLoader.ENABLE_RESTRICTED_EXTENSIONS_PROPERTY)) {
// custom extension class loader is disabled; use normal classpath
return;
}
List<String> extClasspathList = new ArrayList<>();
addExtensionJarPaths(extClasspathList, modules, layout);
// Remove the extensions that were added before this method was called
classpathList.removeAll(extClasspathList);
String extCp = String.join(File.pathSeparator, extClasspathList);
System.setProperty(GhidraClassLoader.CP_EXT, extCp);
}
/** /**
* Add extension module lib jars to the given path list. (This only needed in dev mode to find * Add extension module lib jars to the given path list. (This only needed in dev mode to find
* any pre-built extensions that have been installed, since we already find extension module * any pre-built extensions that have been installed, since we already find extension module
@@ -17,6 +17,7 @@ package utility.application;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import generic.jar.ResourceFile; import generic.jar.ResourceFile;
import ghidra.framework.ApplicationProperties; import ghidra.framework.ApplicationProperties;
@@ -30,7 +31,7 @@ public class DummyApplicationLayout extends ApplicationLayout {
/** /**
* Constructs a new dummy application layout object. * Constructs a new dummy application layout object.
* * @param name the application name
* @throws FileNotFoundException if there was a problem getting a user directory. * @throws FileNotFoundException if there was a problem getting a user directory.
*/ */
public DummyApplicationLayout(String name) throws FileNotFoundException { public DummyApplicationLayout(String name) throws FileNotFoundException {
@@ -48,5 +49,7 @@ public class DummyApplicationLayout extends ApplicationLayout {
// User directories // User directories
userTempDir = ApplicationUtilities.getDefaultUserTempDir(applicationProperties); userTempDir = ApplicationUtilities.getDefaultUserTempDir(applicationProperties);
extensionInstallationDirs = Collections.emptyList();
} }
} }
@@ -102,6 +102,10 @@ VMARGS=-Xshare:off
# Limit on XML parsing. See https://docs.oracle.com/javase/tutorial/jaxp/limits/limits.html # Limit on XML parsing. See https://docs.oracle.com/javase/tutorial/jaxp/limits/limits.html
#VMARGS=-Djdk.xml.totalEntitySizeLimit=50000000 #VMARGS=-Djdk.xml.totalEntitySizeLimit=50000000
# Restrict extensions to their own 'lib' directory for loading non-Ghidra jars. This may be used
# to fix class resolution if multiple extensions include different versions of the same named class.
#VMARGS=-Dghidra.extensions.classpath.restricted=true
# Enables PDB debug logging during import and analysis to .ghidra/.ghidra_ver/pdb.analyzer.log # Enables PDB debug logging during import and analysis to .ghidra/.ghidra_ver/pdb.analyzer.log
#VMARGS=-Dghidra.pdb.logging=true #VMARGS=-Dghidra.pdb.logging=true
@@ -35,13 +35,13 @@ import docking.wizard.WizardManager;
import docking.wizard.WizardPanel; import docking.wizard.WizardPanel;
import generic.theme.GThemeDefaults.Colors; import generic.theme.GThemeDefaults.Colors;
import ghidra.app.plugin.core.archive.RestoreDialog; import ghidra.app.plugin.core.archive.RestoreDialog;
import ghidra.framework.data.GhidraFileData;
import ghidra.framework.data.DefaultProjectData; import ghidra.framework.data.DefaultProjectData;
import ghidra.framework.data.GhidraFileData;
import ghidra.framework.main.*; import ghidra.framework.main.*;
import ghidra.framework.model.*; import ghidra.framework.model.*;
import ghidra.framework.plugintool.dialog.*;
import ghidra.framework.preferences.Preferences; import ghidra.framework.preferences.Preferences;
import ghidra.framework.project.extensions.*; import ghidra.framework.project.extensions.ExtensionTablePanel;
import ghidra.framework.project.extensions.ExtensionTableProvider;
import ghidra.framework.remote.User; import ghidra.framework.remote.User;
import ghidra.framework.store.LockException; import ghidra.framework.store.LockException;
import ghidra.program.database.ProgramContentHandler; import ghidra.program.database.ProgramContentHandler;
@@ -50,6 +50,7 @@ import ghidra.test.ProjectTestUtils;
import ghidra.util.InvalidNameException; import ghidra.util.InvalidNameException;
import ghidra.util.Msg; import ghidra.util.Msg;
import ghidra.util.exception.CancelledException; import ghidra.util.exception.CancelledException;
import ghidra.util.extensions.ExtensionDetails;
import ghidra.util.task.TaskMonitor; import ghidra.util.task.TaskMonitor;
import resources.MultiIcon; import resources.MultiIcon;
@@ -703,7 +704,7 @@ public class FrontEndPluginScreenShots extends GhidraScreenShotGenerator {
Language language = getZ80_LANGUAGE(); Language language = getZ80_LANGUAGE();
DomainFile otherFile = DomainFile otherFile =
ProjectTestUtils.createProgramFile(otherProject, "Program1", language, ProjectTestUtils.createProgramFile(otherProject, "Program1", language,
language.getDefaultCompilerSpec(), null); language.getDefaultCompilerSpec(), null);
ProjectTestUtils.createProgramFile(otherProject, "Program2", language, ProjectTestUtils.createProgramFile(otherProject, "Program2", language,
language.getDefaultCompilerSpec(), null); language.getDefaultCompilerSpec(), null);