mirror of
https://github.com/NationalSecurityAgency/ghidra.git
synced 2025-10-05 19:42:36 +02:00
Merge remote-tracking branch 'origin/GP-3623-dragonmacher-extension-classloader--SQUASHED'
This commit is contained in:
commit
280d5ce8d1
30 changed files with 1079 additions and 731 deletions
|
@ -153,7 +153,7 @@ public class PluginDescription implements Comparable<PluginDescription> {
|
|||
*/
|
||||
public String getModuleName() {
|
||||
if (moduleName == null) {
|
||||
ResourceFile moduleDir = Application.getModuleContainingClass(pluginClass.getName());
|
||||
ResourceFile moduleDir = Application.getModuleContainingClass(pluginClass);
|
||||
moduleName = (moduleDir == null) ? "<No Module>" : moduleDir.getName();
|
||||
}
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
package ghidra.framework.plugintool.util;
|
||||
|
||||
import java.lang.reflect.*;
|
||||
import java.util.List;
|
||||
|
||||
import ghidra.framework.plugintool.*;
|
||||
import ghidra.util.Msg;
|
||||
|
@ -76,6 +77,14 @@ public class PluginUtils {
|
|||
*/
|
||||
public static Class<? extends Plugin> forName(String pluginClassName) throws PluginException {
|
||||
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);
|
||||
if (!Plugin.class.isAssignableFrom(tmpClass)) {
|
||||
throw new PluginException(
|
||||
|
@ -84,7 +93,7 @@ public class PluginUtils {
|
|||
return tmpClass.asSubclass(Plugin.class);
|
||||
}
|
||||
catch (ClassNotFoundException e) {
|
||||
throw new PluginException("Plugin class not found");
|
||||
throw new PluginException("Plugin class not found: " + pluginClassName);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,376 +0,0 @@
|
|||
/* ###
|
||||
* 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 generic.jar.ResourceFile;
|
||||
import generic.json.Json;
|
||||
import ghidra.framework.Application;
|
||||
import ghidra.util.Msg;
|
||||
import utilities.util.FileUtilities;
|
||||
import utility.application.ApplicationLayout;
|
||||
import utility.module.ModuleUtilities;
|
||||
|
||||
/**
|
||||
* Representation of a Ghidra extension. This class encapsulates all information required to
|
||||
* uniquely identify an extension and where (or if) it has been installed.
|
||||
* <p>
|
||||
* Note that hashCode and equals have been implemented for this. Two extension
|
||||
* descriptions are considered equal if they have the same {@link #name} attribute; all other
|
||||
* fields are unimportant except for display purposes.
|
||||
*/
|
||||
public class ExtensionDetails implements Comparable<ExtensionDetails> {
|
||||
|
||||
/** Absolute path to where this extension is installed. If not installed, this will be null. */
|
||||
private File installDir;
|
||||
|
||||
/**
|
||||
* Absolute path to where the original source archive (zip) for this extension can be found. If
|
||||
* there is no archive (likely because this is an extension that comes pre-installed with
|
||||
* Ghidra, or Ghidra is being run in development mode), this will be null.
|
||||
*/
|
||||
private String archivePath;
|
||||
|
||||
/** Name of the extension. This must be unique.*/
|
||||
private String name;
|
||||
|
||||
/** Brief description, for display purposes only.*/
|
||||
private String description;
|
||||
|
||||
/** Date when the extension was created, for display purposes only.*/
|
||||
private String createdOn;
|
||||
|
||||
/** Author of the extension, for display purposes only.*/
|
||||
private String author;
|
||||
|
||||
/** The extension version */
|
||||
private String version;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param name unique name of the extension; cannot be null
|
||||
* @param description brief explanation of what the extension does; can be null
|
||||
* @param author creator of the extension; can be null
|
||||
* @param createdOn creation date of the extension, can be null
|
||||
* @param version the extension version
|
||||
*/
|
||||
public ExtensionDetails(String name, String description, String author, String createdOn,
|
||||
String version) {
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.author = author;
|
||||
this.createdOn = createdOn;
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
final int prime = 31;
|
||||
int result = 1;
|
||||
result = prime * result + ((name == null) ? 0 : name.hashCode());
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj) {
|
||||
return true;
|
||||
}
|
||||
if (obj == null) {
|
||||
return false;
|
||||
}
|
||||
if (getClass() != obj.getClass()) {
|
||||
return false;
|
||||
}
|
||||
ExtensionDetails other = (ExtensionDetails) obj;
|
||||
if (name == null) {
|
||||
if (other.name != null) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (!name.equals(other.name)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the location where this extension is installed. If the extension is not installed
|
||||
* this will be null.
|
||||
*
|
||||
* @return the extension path, or null
|
||||
*/
|
||||
public String getInstallPath() {
|
||||
if (installDir != null) {
|
||||
return installDir.getAbsolutePath();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public File getInstallDir() {
|
||||
return installDir;
|
||||
}
|
||||
|
||||
public void setInstallDir(File installDir) {
|
||||
this.installDir = installDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the location where the extension archive is located. The extension archive concept
|
||||
* is not used for all extensions, but is used for delivering extensions as part of a
|
||||
* distribution.
|
||||
*
|
||||
* @return the archive path, or null
|
||||
* @see ApplicationLayout#getExtensionArchiveDir()
|
||||
*/
|
||||
public String getArchivePath() {
|
||||
return archivePath;
|
||||
}
|
||||
|
||||
public void setArchivePath(String path) {
|
||||
this.archivePath = path;
|
||||
}
|
||||
|
||||
public boolean isFromArchive() {
|
||||
return archivePath != null;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public String getAuthor() {
|
||||
return author;
|
||||
}
|
||||
|
||||
public void setAuthor(String author) {
|
||||
this.author = author;
|
||||
}
|
||||
|
||||
public String getCreatedOn() {
|
||||
return createdOn;
|
||||
}
|
||||
|
||||
public void setCreatedOn(String date) {
|
||||
this.createdOn = date;
|
||||
}
|
||||
|
||||
public String getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public void setVersion(String version) {
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* still on the filesystem, may be in use by the tool, but will be removed upon restart.
|
||||
* <p>
|
||||
* 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
|
||||
* checking the appropriate checkbox in the {@link ExtensionTableModel}), the only thing
|
||||
* that is done is to remove this manifest file, which tells the {@link ExtensionTableProvider}
|
||||
* to remove the entire extension directory on the next launch.
|
||||
*
|
||||
* @return true if the extension is installed.
|
||||
*/
|
||||
public boolean isInstalled() {
|
||||
if (installDir == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If running out of a jar and the install path is valid, just return true. The alternative
|
||||
// would be to inspect the jar and verify that the install path is there and is valid, but
|
||||
// that's overkill.
|
||||
if (Application.inSingleJarMode()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
File f = new File(installDir, ModuleUtilities.MANIFEST_FILE_NAME);
|
||||
return f.exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this extension is marked to be uninstalled. The contents of the extension
|
||||
* still exist and the tool may still be using the extension, but on restart, the extension will
|
||||
* be removed.
|
||||
*
|
||||
* @return true if marked for uninstall
|
||||
*/
|
||||
public boolean isPendingUninstall() {
|
||||
if (installDir == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Application.inSingleJarMode()) {
|
||||
return false; // can't uninstall from single jar mode
|
||||
}
|
||||
|
||||
File f = new File(installDir, ModuleUtilities.MANIFEST_FILE_NAME_UNINSTALLED);
|
||||
return f.exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this extension is installed under an installation folder or inside of a
|
||||
* source control repository folder.
|
||||
* @return true if this extension is installed under an installation folder or inside of a
|
||||
* source control repository folder.
|
||||
*/
|
||||
public boolean isInstalledInInstallationFolder() {
|
||||
if (installDir == null) {
|
||||
return false; // not installed
|
||||
}
|
||||
|
||||
ApplicationLayout layout = Application.getApplicationLayout();
|
||||
|
||||
List<ResourceFile> extDirs = layout.getExtensionInstallationDirs();
|
||||
if (extDirs.size() < 2) {
|
||||
Msg.trace(this, "Unexpected extension installation dirs; revisit this assumption");
|
||||
return false;
|
||||
}
|
||||
|
||||
// extDirs.get(0) is the user extension dir
|
||||
ResourceFile appExtDir = extDirs.get(1);
|
||||
if (FileUtilities.isPathContainedWithin(appExtDir.getFile(false), installDir)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the module manifest and extension properties file that are in an installed state to
|
||||
* an uninstalled state.
|
||||
*
|
||||
* Specifically, the following will be renamed:
|
||||
* <UL>
|
||||
* <LI>Module.manifest to Module.manifest.uninstalled</LI>
|
||||
* <LI>extension.properties = extension.properties.uninstalled</LI>
|
||||
* </UL>
|
||||
*
|
||||
* @return false if any renames fail
|
||||
*/
|
||||
public boolean markForUninstall() {
|
||||
|
||||
if (installDir == null) {
|
||||
return false; // already marked as uninstalled
|
||||
}
|
||||
|
||||
Msg.trace(this, "Marking extension for uninstall '" + installDir + "'");
|
||||
|
||||
boolean success = true;
|
||||
File manifest = new File(installDir, ModuleUtilities.MANIFEST_FILE_NAME);
|
||||
if (manifest.exists()) {
|
||||
File newFile = new File(installDir, ModuleUtilities.MANIFEST_FILE_NAME_UNINSTALLED);
|
||||
if (!manifest.renameTo(newFile)) {
|
||||
Msg.trace(this, "Unable to rename module manifest file: " + manifest);
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
else {
|
||||
Msg.trace(this, "No manifest file found for extension '" + name + "'");
|
||||
}
|
||||
|
||||
File properties = new File(installDir, ExtensionUtils.PROPERTIES_FILE_NAME);
|
||||
if (properties.exists()) {
|
||||
File newFile = new File(installDir, ExtensionUtils.PROPERTIES_FILE_NAME_UNINSTALLED);
|
||||
if (!properties.renameTo(newFile)) {
|
||||
Msg.trace(this, "Unable to rename properties file: " + properties);
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
else {
|
||||
Msg.trace(this, "No properties file found for extension '" + name + "'");
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* A companion method for {@link #markForUninstall()} that allows extensions marked for cleanup
|
||||
* to be restored to the installed state.
|
||||
* <p>
|
||||
* Specifically, the following will be renamed:
|
||||
* <UL>
|
||||
* <LI>Module.manifest.uninstalled to Module.manifest</LI>
|
||||
* <LI>extension.properties.uninstalled to extension.properties</LI>
|
||||
* </UL>
|
||||
* @return true if successful
|
||||
*/
|
||||
public boolean clearMarkForUninstall() {
|
||||
|
||||
if (installDir == null) {
|
||||
Msg.error(ExtensionUtils.class,
|
||||
"Cannot restore extension; extension installation dir is missing for: " + name);
|
||||
return false; // already marked as uninstalled
|
||||
}
|
||||
|
||||
Msg.trace(this, "Restoring extension state files for '" + installDir + "'");
|
||||
|
||||
boolean success = true;
|
||||
File manifest = new File(installDir, ModuleUtilities.MANIFEST_FILE_NAME_UNINSTALLED);
|
||||
if (manifest.exists()) {
|
||||
File newFile = new File(installDir, ModuleUtilities.MANIFEST_FILE_NAME);
|
||||
if (!manifest.renameTo(newFile)) {
|
||||
Msg.trace(this, "Unable to rename module manifest file: " + manifest);
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
else {
|
||||
Msg.trace(this, "No manifest file found for extension '" + name + "'");
|
||||
}
|
||||
|
||||
File properties = new File(installDir, ExtensionUtils.PROPERTIES_FILE_NAME_UNINSTALLED);
|
||||
if (properties.exists()) {
|
||||
File newFile = new File(installDir, ExtensionUtils.PROPERTIES_FILE_NAME);
|
||||
if (!properties.renameTo(newFile)) {
|
||||
Msg.trace(this, "Unable to rename properties file: " + properties);
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
else {
|
||||
Msg.trace(this, "No properties file found for extension '" + name + "'");
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(ExtensionDetails other) {
|
||||
return name.compareTo(other.name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return Json.toString(this);
|
||||
}
|
||||
}
|
|
@ -23,6 +23,7 @@ import javax.swing.text.SimpleAttributeSet;
|
|||
import docking.widgets.table.threaded.ThreadedTableModelListener;
|
||||
import generic.theme.GColor;
|
||||
import ghidra.framework.plugintool.dialog.AbstractDetailsPanel;
|
||||
import ghidra.util.extensions.ExtensionDetails;
|
||||
|
||||
/**
|
||||
* 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.datastruct.Accumulator;
|
||||
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.GColumnRenderer;
|
||||
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
|
||||
// archives folder'
|
||||
if (extension.isFromArchive()) {
|
||||
if (ExtensionUtils.installExtensionFromArchive(extension)) {
|
||||
if (ExtensionInstaller.installExtensionFromArchive(extension)) {
|
||||
refreshTable();
|
||||
}
|
||||
return;
|
||||
|
@ -192,6 +194,7 @@ class ExtensionTableModel extends ThreadedTableModel<ExtensionDetails, Object> {
|
|||
return;
|
||||
}
|
||||
|
||||
ExtensionUtils.reload();
|
||||
Set<ExtensionDetails> archived = ExtensionUtils.getArchiveExtensions();
|
||||
Set<ExtensionDetails> installed = ExtensionUtils.getInstalledExtensions();
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ import docking.widgets.table.*;
|
|||
import ghidra.app.util.GenericHelpTopics;
|
||||
import ghidra.framework.plugintool.PluginTool;
|
||||
import ghidra.util.HelpLocation;
|
||||
import ghidra.util.extensions.ExtensionDetails;
|
||||
import help.Help;
|
||||
import help.HelpService;
|
||||
|
||||
|
|
|
@ -33,6 +33,7 @@ import ghidra.framework.Application;
|
|||
import ghidra.framework.plugintool.PluginTool;
|
||||
import ghidra.util.HelpLocation;
|
||||
import ghidra.util.Msg;
|
||||
import ghidra.util.extensions.ExtensionUtils;
|
||||
import ghidra.util.filechooser.GhidraFileChooserModel;
|
||||
import ghidra.util.filechooser.GhidraFileFilter;
|
||||
import resources.Icons;
|
||||
|
@ -105,7 +106,7 @@ public class ExtensionTableProvider extends DialogComponentProvider {
|
|||
super.dialogClosed();
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
@ -176,7 +177,7 @@ public class ExtensionTableProvider extends DialogComponentProvider {
|
|||
continue;
|
||||
}
|
||||
|
||||
boolean success = ExtensionUtils.install(file);
|
||||
boolean success = ExtensionInstaller.install(file);
|
||||
didInstall |= success;
|
||||
}
|
||||
return didInstall;
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -16,7 +16,6 @@
|
|||
package ghidra.framework.project.tool;
|
||||
|
||||
import java.io.File;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
|
@ -29,10 +28,10 @@ import generic.json.Json;
|
|||
import ghidra.framework.plugintool.*;
|
||||
import ghidra.framework.plugintool.dialog.PluginInstallerDialog;
|
||||
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.classfinder.ClassSearcher;
|
||||
import ghidra.util.extensions.ExtensionDetails;
|
||||
import ghidra.util.extensions.ExtensionUtils;
|
||||
import ghidra.util.xml.XmlUtilities;
|
||||
import utilities.util.FileUtilities;
|
||||
|
||||
|
@ -191,12 +190,7 @@ class ExtensionManager {
|
|||
Set<PluginPath> pluginPaths = getPluginPaths();
|
||||
Set<Class<?>> extensionPlugins = new HashSet<>();
|
||||
for (ExtensionDetails extension : extensions) {
|
||||
File installDir = extension.getInstallDir();
|
||||
if (installDir == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Set<Class<?>> classes = findPluginsLoadedFromExtension(installDir, pluginPaths);
|
||||
Set<Class<?>> classes = findPluginsLoadedFromExtension(extension, pluginPaths);
|
||||
extensionPlugins.addAll(classes);
|
||||
}
|
||||
|
||||
|
@ -219,28 +213,31 @@ class ExtensionManager {
|
|||
* classpath. For each class, the original resource file is compared against the
|
||||
* 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
|
||||
* @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<Class<?>> result = new HashSet<>();
|
||||
if (!extension.isInstalled()) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
|
||||
// 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
|
||||
// extension the given extension directory
|
||||
Set<Class<?>> result = new HashSet<>();
|
||||
for (PluginPath pluginPath : pluginPaths) {
|
||||
if (pluginPath.isFrom(dir)) {
|
||||
if (pluginPath.isFrom(extension.getInstallDir())) {
|
||||
result.add(pluginPath.getPluginClass());
|
||||
continue;
|
||||
}
|
||||
|
||||
for (String jarPath : jarPaths) {
|
||||
if (pluginPath.isFrom(jarPath)) {
|
||||
for (URL jarUrl : jarPaths) {
|
||||
if (pluginPath.isFrom(jarUrl)) {
|
||||
result.add(pluginPath.getPluginClass());
|
||||
}
|
||||
}
|
||||
|
@ -248,45 +245,6 @@ class ExtensionManager {
|
|||
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 Class<? extends Plugin> pluginClass;
|
||||
private String pluginLocation;
|
||||
|
@ -304,7 +262,8 @@ class ExtensionManager {
|
|||
return FileUtilities.isPathContainedWithin(dir, pluginFile);
|
||||
}
|
||||
|
||||
boolean isFrom(String jarPath) {
|
||||
boolean isFrom(URL jarUrl) {
|
||||
String jarPath = jarUrl.getPath();
|
||||
return pluginLocation.contains(jarPath);
|
||||
}
|
||||
|
||||
|
|
|
@ -31,15 +31,17 @@ import docking.DialogComponentProvider;
|
|||
import docking.test.AbstractDockingTest;
|
||||
import generic.jar.ResourceFile;
|
||||
import ghidra.framework.Application;
|
||||
import ghidra.util.extensions.ExtensionDetails;
|
||||
import ghidra.util.extensions.ExtensionUtils;
|
||||
import utilities.util.FileUtilities;
|
||||
import utility.application.ApplicationLayout;
|
||||
import utility.function.ExceptionalCallback;
|
||||
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 TEST_EXT_NAME = "test";
|
||||
|
@ -87,7 +89,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
|
|||
|
||||
// Create an extension and install it.
|
||||
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
|
||||
checkExtensionInstalledInFilesystem(TEST_EXT_NAME);
|
||||
|
@ -101,7 +103,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
|
|||
|
||||
// Create an extension and install it.
|
||||
File file = createExtensionFolderInArchiveDir();
|
||||
ExtensionUtils.install(file);
|
||||
ExtensionInstaller.install(file);
|
||||
|
||||
// Verify the extension is in the install folder and has the correct name
|
||||
checkExtensionInstalledInFilesystem(TEST_EXT_NAME);
|
||||
|
@ -142,10 +144,9 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
|
|||
@Test
|
||||
public void testBadInputs() throws Exception {
|
||||
errorsExpected(() -> {
|
||||
assertFalse(ExtensionUtils.isExtension(null));
|
||||
assertFalse(ExtensionUtils.install(new File("this/file/does/not/exist")));
|
||||
assertFalse(ExtensionUtils.install(null));
|
||||
assertFalse(ExtensionUtils.installExtensionFromArchive(null));
|
||||
assertFalse(ExtensionInstaller.install(new File("this/file/does/not/exist")));
|
||||
assertFalse(ExtensionInstaller.install(null));
|
||||
assertFalse(ExtensionInstaller.installExtensionFromArchive(null));
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -156,7 +157,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
|
|||
extension.setArchivePath(zipFile.getAbsolutePath());
|
||||
String ghidraVersion = Application.getApplicationVersion();
|
||||
extension.setVersion(ghidraVersion);
|
||||
assertTrue(ExtensionUtils.installExtensionFromArchive(extension));
|
||||
assertTrue(ExtensionInstaller.installExtensionFromArchive(extension));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -168,7 +169,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
|
|||
|
||||
AtomicBoolean didInstall = new AtomicBoolean();
|
||||
runSwingLater(() -> {
|
||||
didInstall.set(ExtensionUtils.installExtensionFromArchive(extension));
|
||||
didInstall.set(ExtensionInstaller.installExtensionFromArchive(extension));
|
||||
});
|
||||
|
||||
DialogComponentProvider confirmDialog =
|
||||
|
@ -187,7 +188,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
|
|||
|
||||
AtomicBoolean didInstall = new AtomicBoolean();
|
||||
runSwingLater(() -> {
|
||||
didInstall.set(ExtensionUtils.installExtensionFromArchive(extension));
|
||||
didInstall.set(ExtensionInstaller.installExtensionFromArchive(extension));
|
||||
});
|
||||
|
||||
DialogComponentProvider confirmDialog =
|
||||
|
@ -207,7 +208,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
|
|||
|
||||
AtomicBoolean didInstall = new AtomicBoolean();
|
||||
runSwingLater(() -> {
|
||||
didInstall.set(ExtensionUtils.installExtensionFromArchive(extension));
|
||||
didInstall.set(ExtensionInstaller.installExtensionFromArchive(extension));
|
||||
});
|
||||
|
||||
DialogComponentProvider confirmDialog =
|
||||
|
@ -221,7 +222,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
|
|||
public void testMarkForUninstall_ClearMark() throws Exception {
|
||||
|
||||
File externalFolder = createExternalExtensionInFolder();
|
||||
assertTrue(ExtensionUtils.install(externalFolder));
|
||||
assertTrue(ExtensionInstaller.install(externalFolder));
|
||||
|
||||
ExtensionDetails extension = assertExtensionInstalled(TEST_EXT_NAME);
|
||||
|
||||
|
@ -238,7 +239,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
|
|||
public void testCleanupUninstalledExtions_WithExtensionMarkedForUninstall() throws Exception {
|
||||
|
||||
File externalFolder = createExternalExtensionInFolder();
|
||||
assertTrue(ExtensionUtils.install(externalFolder));
|
||||
assertTrue(ExtensionInstaller.install(externalFolder));
|
||||
|
||||
ExtensionDetails extension = assertExtensionInstalled(TEST_EXT_NAME);
|
||||
|
||||
|
@ -255,8 +256,8 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
|
|||
public void testCleanupUninstalledExtions_SomeExtensionMarkedForUninstall() throws Exception {
|
||||
|
||||
List<File> extensionFolders = createTwoExternalExtensionsInFolder();
|
||||
assertTrue(ExtensionUtils.install(extensionFolders.get(0)));
|
||||
assertTrue(ExtensionUtils.install(extensionFolders.get(1)));
|
||||
assertTrue(ExtensionInstaller.install(extensionFolders.get(0)));
|
||||
assertTrue(ExtensionInstaller.install(extensionFolders.get(1)));
|
||||
|
||||
Set<ExtensionDetails> extensions = ExtensionUtils.getInstalledExtensions();
|
||||
assertEquals(extensions.size(), 2);
|
||||
|
@ -279,7 +280,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
|
|||
public void testCleanupUninstalledExtions_NoExtensionsMarkedForUninstall() throws Exception {
|
||||
|
||||
File externalFolder = createExternalExtensionInFolder();
|
||||
assertTrue(ExtensionUtils.install(externalFolder));
|
||||
assertTrue(ExtensionInstaller.install(externalFolder));
|
||||
assertExtensionInstalled(TEST_EXT_NAME);
|
||||
|
||||
// This should not uninstall any extensions
|
||||
|
@ -299,7 +300,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
|
|||
String appVersion = Application.getApplicationVersion();
|
||||
File extensionFolder =
|
||||
doCreateExternalExtensionInFolder(buildFolder, TEST_EXT_NAME, appVersion);
|
||||
assertTrue(ExtensionUtils.install(extensionFolder));
|
||||
assertTrue(ExtensionInstaller.install(extensionFolder));
|
||||
|
||||
Set<ExtensionDetails> extensions = ExtensionUtils.getInstalledExtensions();
|
||||
assertEquals(extensions.size(), 1);
|
||||
|
@ -313,7 +314,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
|
|||
|
||||
AtomicBoolean didInstall = new AtomicBoolean();
|
||||
runSwingLater(() -> {
|
||||
didInstall.set(ExtensionUtils.install(extensionFolder2));
|
||||
didInstall.set(ExtensionInstaller.install(extensionFolder2));
|
||||
});
|
||||
|
||||
DialogComponentProvider confirmDialog =
|
||||
|
@ -329,7 +330,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
|
|||
checkCleanInstall();
|
||||
|
||||
runSwingLater(() -> {
|
||||
didInstall.set(ExtensionUtils.install(extensionFolder2));
|
||||
didInstall.set(ExtensionInstaller.install(extensionFolder2));
|
||||
});
|
||||
|
||||
// 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();
|
||||
File extensionFolder =
|
||||
doCreateExternalExtensionInFolder(buildFolder, TEST_EXT_NAME, appVersion);
|
||||
assertTrue(ExtensionUtils.install(extensionFolder));
|
||||
assertTrue(ExtensionInstaller.install(extensionFolder));
|
||||
|
||||
// create another extension Foo v2
|
||||
File buildFolder2 = createTempDirectory("TestExtensionParentDir2");
|
||||
|
@ -359,7 +360,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
|
|||
|
||||
AtomicBoolean didInstall = new AtomicBoolean();
|
||||
runSwingLater(() -> {
|
||||
didInstall.set(ExtensionUtils.install(extensionFolder2));
|
||||
didInstall.set(ExtensionInstaller.install(extensionFolder2));
|
||||
});
|
||||
|
||||
DialogComponentProvider confirmDialog =
|
||||
|
@ -379,7 +380,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
|
|||
String appVersion = Application.getApplicationVersion();
|
||||
File extensionFolder =
|
||||
doCreateExternalExtensionInFolder(buildFolder, TEST_EXT_NAME, appVersion);
|
||||
assertTrue(ExtensionUtils.install(extensionFolder));
|
||||
assertTrue(ExtensionInstaller.install(extensionFolder));
|
||||
|
||||
Set<ExtensionDetails> extensions = ExtensionUtils.getInstalledExtensions();
|
||||
assertEquals(extensions.size(), 1);
|
||||
|
@ -393,7 +394,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
|
|||
|
||||
AtomicBoolean didInstall = new AtomicBoolean();
|
||||
runSwingLater(() -> {
|
||||
didInstall.set(ExtensionUtils.install(extensionFolder2));
|
||||
didInstall.set(ExtensionInstaller.install(extensionFolder2));
|
||||
});
|
||||
|
||||
DialogComponentProvider confirmDialog =
|
||||
|
@ -409,7 +410,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
|
|||
checkCleanInstall();
|
||||
|
||||
runSwingLater(() -> {
|
||||
didInstall.set(ExtensionUtils.install(extensionFolder2));
|
||||
didInstall.set(ExtensionInstaller.install(extensionFolder2));
|
||||
});
|
||||
|
||||
waitFor(didInstall);
|
||||
|
@ -437,7 +438,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
|
|||
|
||||
errorsExpected(() -> {
|
||||
File zipFile = createZipWithMultipleExtensions();
|
||||
assertFalse(ExtensionUtils.install(zipFile));
|
||||
assertFalse(ExtensionInstaller.install(zipFile));
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -452,7 +453,7 @@ public class ExtensionUtilsTest extends AbstractDockingTest {
|
|||
|
||||
String nameProperty = "ExtensionNamedFoo";
|
||||
File externalFolder = createExtensionWithMismatchingNamePropertyString(nameProperty);
|
||||
assertTrue(ExtensionUtils.install(externalFolder));
|
||||
assertTrue(ExtensionInstaller.install(externalFolder));
|
||||
|
||||
ExtensionDetails extension = assertExtensionInstalled(nameProperty);
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue