diff --git a/Ghidra/Features/Base/src/main/help/help/topics/FrontEndPlugin/Ghidra_Front_end.htm b/Ghidra/Features/Base/src/main/help/help/topics/FrontEndPlugin/Ghidra_Front_end.htm index 41896f838c..6a3ffc3641 100644 --- a/Ghidra/Features/Base/src/main/help/help/topics/FrontEndPlugin/Ghidra_Front_end.htm +++ b/Ghidra/Features/Base/src/main/help/help/topics/FrontEndPlugin/Ghidra_Front_end.htm @@ -115,7 +115,9 @@ "help/topics/VersionControl/project_repository.htm#SampleCheckOutIcon">checked out. In addition, unique icons are used to reflect content-type and if it corresponds to a link-file referring to another file or folder (see creating links). - Open this view by activating the project window "Tree View" tab.

+ Open this view by activating the project window "Tree View" tab. Within the tree view + internally linked-folders may be expanded to reveal the linked content which corresponds + to another folder within the project.

Although Ghidra allows a folder and file within the same parent folder to have the same name, it is recommended this be avoided if possible. @@ -313,6 +315,26 @@ project view once opened. + + +

Select Real File or Folder

+ +
+

Select a folder or file tree node from an expanded linked-folder or sub-folder + node. Content is considered linked if one of its parent nodes corresponds to an + expanded folder-link. This linked-content corresponds to a real file or folder + contained within another folder. The ability to select the real file or folder + may be useful when trying to understand the true origin of such content since this + path is not displayed. +

+ +
    +
  1. + Select a folder or file tree node from an expanded linked-folder or linked-sub-folder + node, right mouse click and choose the Select Real File or Select Real Folder + option. The real file or folder will be selected within the tree if possible.
  2. +
+
@@ -656,7 +678,8 @@ See Ghidra URL formats below. Since a folder link is stored as a file, it may appear with various icon states which correspond to version control. Folder links only support a single version and may not - be modified. + be modified. The tree may permit expanding such nodes to reveal their linked-content + as files and sub-folders. diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/progmgr/ProgramLocator.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/progmgr/ProgramLocator.java index a58c60f2eb..dc45224df1 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/progmgr/ProgramLocator.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/progmgr/ProgramLocator.java @@ -85,7 +85,7 @@ public class ProgramLocator { try { // Attempt to resolve to actual linked-file to allow for // direct URL reference - domainFile = linkedFile.getLinkedFile(); + domainFile = linkedFile.getRealFile(); } catch (IOException e) { Msg.error(this, "Failed to resolve linked-file", e); diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/util/task/ProgramOpener.java b/Ghidra/Features/Base/src/main/java/ghidra/app/util/task/ProgramOpener.java index bd2f805109..be51454486 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/util/task/ProgramOpener.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/util/task/ProgramOpener.java @@ -18,14 +18,17 @@ package ghidra.app.util.task; import java.io.IOException; import java.net.URL; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; import docking.widgets.OptionDialog; import ghidra.app.plugin.core.progmgr.ProgramLocator; import ghidra.app.util.dialog.CheckoutDialog; import ghidra.framework.client.ClientUtil; import ghidra.framework.client.RepositoryAdapter; +import ghidra.framework.data.LinkHandler.LinkStatus; import ghidra.framework.main.AppInfo; import ghidra.framework.model.DomainFile; +import ghidra.framework.model.LinkFileInfo; import ghidra.framework.protocol.ghidra.GhidraURLQuery; import ghidra.framework.protocol.ghidra.GhidraURLQuery.LinkFileControl; import ghidra.framework.protocol.ghidra.GhidraURLResultHandlerAdapter; @@ -142,6 +145,18 @@ public class ProgramOpener { } private Program openNormal(DomainFile domainFile, TaskMonitor monitor) { + + LinkFileInfo linkInfo = domainFile.getLinkInfo(); + if (linkInfo != null) { + StringBuilder buf = new StringBuilder(); + LinkStatus linkStatus = linkInfo.getLinkStatus(m -> buf.append(m)); + if (linkStatus == LinkStatus.BROKEN) { + Msg.showError(this, null, "Error Opening " + domainFile.getName(), + "Failed to open Program Link " + domainFile.getPathname() + "\n" + buf); + return null; + } + } + String filename = domainFile.getName(); performOptionalCheckout(domainFile, monitor); try { diff --git a/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/store/RepositoryFolder.java b/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/store/RepositoryFolder.java index 01465e7121..40bdf83ae2 100644 --- a/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/store/RepositoryFolder.java +++ b/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/store/RepositoryFolder.java @@ -24,7 +24,8 @@ import org.apache.logging.log4j.Logger; import db.buffers.LocalManagedBufferFile; import ghidra.framework.store.*; -import ghidra.framework.store.local.*; +import ghidra.framework.store.local.LocalFileSystem; +import ghidra.framework.store.local.LocalFolderItem; import ghidra.server.Repository; import ghidra.server.RepositoryManager; import ghidra.util.InvalidNameException; @@ -282,11 +283,8 @@ public class RepositoryFolder { throw new DuplicateFileException(itemName + " already exists"); } - LocalTextDataItem textDataItem = fileSystem.createTextDataItem(getPathname(), itemName, - fileID, contentType, textData, null); // comment conveyed with Version info below - - Version singleVersion = new Version(1, System.currentTimeMillis(), user, comment); - textDataItem.setVersionInfo(singleVersion); + fileSystem.createTextDataItem(getPathname(), itemName, fileID, contentType, textData, + comment, user); RepositoryFile rf = new RepositoryFile(repository, fileSystem, this, itemName); fileMap.put(itemName, rf); diff --git a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/FileSystem.java b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/FileSystem.java index 5345dae893..9cf2892ff9 100644 --- a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/FileSystem.java +++ b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/FileSystem.java @@ -211,13 +211,14 @@ public interface FileSystem { * @param contentType application defined content type * @param textData text data (required) * @param comment file comment (may be null, only used if versioning is enabled) + * @param user name of user creating item (required for local versioned item) * @return new data file * @throws DuplicateFileException Thrown if a folderItem with that name already exists. * @throws InvalidNameException if the name has illegal characters. * @throws IOException if an IO error occurs. */ public TextDataItem createTextDataItem(String parentPath, String name, String fileID, - String contentType, String textData, String comment) + String contentType, String textData, String comment, String user) throws InvalidNameException, IOException; /** @@ -344,23 +345,22 @@ public interface FileSystem { */ public static String normalizePath(String path) throws IllegalArgumentException { if (!path.startsWith(SEPARATOR)) { - throw new IllegalArgumentException("Absolute path required"); + throw new IllegalArgumentException("Absolute path required: " + path); } String[] split = path.split(SEPARATOR); ArrayList elements = new ArrayList<>(); + elements.add(SEPARATOR); for (int i = 1; i < split.length; i++) { String e = split[i]; if (e.length() == 0) { throw new IllegalArgumentException("Invalid path with empty element: " + path); } if ("..".equals(e)) { - try { - // remove last element - elements.removeLast(); - } - catch (NoSuchElementException ex) { + // remove last element + elements.removeLast(); + if (elements.size() == 0) { throw new IllegalArgumentException("Invalid path: " + path); } } @@ -369,6 +369,9 @@ public interface FileSystem { continue; } else { + if (i < (split.length - 1)) { + e += SEPARATOR; + } elements.add(e); } } @@ -379,9 +382,11 @@ public interface FileSystem { StringBuilder buf = new StringBuilder(); for (String e : elements) { - buf.append(SEPARATOR); buf.append(e); } + if (path.endsWith(SEPARATOR)) { + buf.append(SEPARATOR); + } return buf.toString(); } diff --git a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/local/LocalFileSystem.java b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/local/LocalFileSystem.java index 6c74955c57..4975931845 100644 --- a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/local/LocalFileSystem.java +++ b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/local/LocalFileSystem.java @@ -556,7 +556,7 @@ public abstract class LocalFileSystem implements FileSystem { @Override public synchronized LocalTextDataItem createTextDataItem(String parentPath, String name, - String fileID, String contentType, String textData, String ignoredComment) + String fileID, String contentType, String textData, String comment, String user) throws InvalidNameException, IOException { // comment is ignored @@ -573,6 +573,12 @@ public abstract class LocalFileSystem implements FileSystem { try { ItemPropertyFile propertyFile = itemStorage.getPropertyFile(); linkFile = new LocalTextDataItem(this, propertyFile, fileID, contentType, textData); + + if (isVersioned) { + Version singleVersion = new Version(1, System.currentTimeMillis(), user, comment); + linkFile.setVersionInfo(singleVersion); + } + linkFile.log("file created", getUserName()); } finally { @@ -822,30 +828,40 @@ public abstract class LocalFileSystem implements FileSystem { } /** - * Returns the full path for a specific folder or item + * Returns the full path for a named folder or item within a parent folder * @param parentPath full parent path * @param name child folder or item name * @return pathname */ - protected final static String getPath(String parentPath, String name) { + public final static String getPath(String parentPath, String name) { if (parentPath.length() == 1) { return parentPath + name; } return parentPath + SEPARATOR_CHAR + name; } - protected final static String getParentPath(String path) { - if (path.length() == 1) { - return null; - } + /** + * Returns the full parent path for a specific folder or item path + * @param path full path of folder or item + * @return parent path or null if "/" path was specified + */ + public final static String getParentPath(String path) { int index = path.lastIndexOf(SEPARATOR_CHAR); if (index == 0) { + if (path.length() == 1) { + return null; + } return SEPARATOR; } return path.substring(0, index); } - protected final static String getName(String path) { + /** + * Returns the name for a specific folder or item path + * @param path full path of folder or item + * @return parent path or null if "/" path was specified + */ + public final static String getName(String path) { if (path.length() == 1) { return path; } diff --git a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/local/LocalFolderItem.java b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/local/LocalFolderItem.java index b6c90e5be5..86849914e6 100644 --- a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/local/LocalFolderItem.java +++ b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/local/LocalFolderItem.java @@ -89,14 +89,14 @@ public abstract class LocalFolderItem implements FolderItem { * @param useDataDir if true the getDataDir() method must return an appropriate * directory for data storage. * @param create if true the data directory will be created - * @throws IOException + * @throws IOException if an IO error occurs */ LocalFolderItem(LocalFileSystem fileSystem, ItemPropertyFile propertyFile, boolean useDataDir, boolean create) throws IOException { this.fileSystem = fileSystem; this.propertyFile = propertyFile; this.isVersioned = fileSystem.isVersioned(); - this.useDataDir = useDataDir || isVersioned; + this.useDataDir = useDataDir; boolean success = false; try { diff --git a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/remote/RemoteFileSystem.java b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/remote/RemoteFileSystem.java index 1c57805418..121bb4a7f2 100644 --- a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/remote/RemoteFileSystem.java +++ b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/remote/RemoteFileSystem.java @@ -229,7 +229,7 @@ public class RemoteFileSystem implements FileSystem, RemoteAdapterListener { @Override public TextDataItem createTextDataItem(String parentPath, String name, String fileID, - String contentType, String textData, String comment) + String contentType, String textData, String comment, String ignoredUser) throws InvalidNameException, IOException { repository.createTextDataFile(parentPath, name, fileID, contentType, textData, comment); return (TextDataItem) getItem(parentPath, name); diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DefaultProjectData.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DefaultProjectData.java index 283a78cd7c..9be18a319a 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DefaultProjectData.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DefaultProjectData.java @@ -729,6 +729,10 @@ public class DefaultProjectData implements ProjectData { @Override public void refresh(boolean force) { + // FIXME: We ignore force. We are forcing full recursive refresh on non-visited folders + // only - seems inconsistent!! + // Underlying method fails if recursive and force is false. + // NOTE: Refresh really does nothing if force is false and folder already visited try { rootFolderData.refresh(true, true, projectDisposalMonitor); } @@ -1057,7 +1061,8 @@ public class DefaultProjectData implements ProjectData { @Override public void folderCreated(final String parentPath, final String name) { synchronized (fileSystem) { - GhidraFolderData folderData = rootFolderData.getFolderPathData(parentPath, true); + boolean lazy = !rootFolderData.mustVisit(parentPath); + GhidraFolderData folderData = rootFolderData.getFolderPathData(parentPath, lazy); if (folderData != null) { try { folderData.folderChanged(name); @@ -1111,7 +1116,9 @@ public class DefaultProjectData implements ProjectData { // ignore } } - folderData = rootFolderData.getFolderPathData(newParentPath, true); + + boolean lazy = !rootFolderData.mustVisit(newParentPath); + folderData = rootFolderData.getFolderPathData(newParentPath, lazy); if (folderData != null) { try { folderData.folderChanged(folderName); @@ -1338,7 +1345,7 @@ public class DefaultProjectData implements ProjectData { } } - GhidraFolderData getRootFolderData() { + RootGhidraFolderData getRootFolderData() { return rootFolderData; } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/GhidraFile.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/GhidraFile.java index 6ac47181c5..8c271c40cc 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/GhidraFile.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/GhidraFile.java @@ -165,6 +165,16 @@ public class GhidraFile implements DomainFile, LinkFileInfo { } } + @Override + public boolean isFolderLink() { + try { + return getFileData().isFolderLink(); + } + catch (IOException e) { + return false; + } + } + @Override public LinkFileInfo getLinkInfo() { return isLink() ? this : null; @@ -642,7 +652,7 @@ public class GhidraFile implements DomainFile, LinkFileInfo { catch (IOException e) { fileError(e); } - return new HashMap<>(); + return Map.of(); } void fileChanged() { diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/GhidraFileData.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/GhidraFileData.java index f718b1b098..da89ff578e 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/GhidraFileData.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/GhidraFileData.java @@ -81,7 +81,12 @@ public class GhidraFileData { private GhidraFolderData parent; private String name; private String fileID; + private String contentType; + private String linkPath; + private String absoluteLinkPath; + private boolean isLink; + private boolean isFolderLink; private LocalFolderItem folderItem; private FolderItem versionedFolderItem; @@ -98,7 +103,10 @@ public class GhidraFileData { // longer used. /** - * Construct a file instance with a specified name and a correpsonding parent folder + * Construct a file instance with a specified name and a correpsonding parent folder. + * It is important that this object only be instantiated by the + * {@link GhidraFolderData} parent supplied and properly cached and tracked to ensure proper + * tracking and link registration. * @param parent parent folder * @param name file name * @throws IOException if an IO error occurs @@ -118,7 +126,9 @@ public class GhidraFileData { /** * Construct a new file instance with a specified name and a corresponding parent folder using - * up-to-date folder items. + * up-to-date folder items. It is important that this object only be instantiated by the + * {@link GhidraFolderData} parent supplied and properly cached and tracked to ensure proper + * tracking and link registration. * @param parent parent folder * @param name file name * @param folderItem local folder item @@ -138,10 +148,15 @@ public class GhidraFileData { validateCheckout(); updateFileID(); + + registerLinkFile(); } void refresh(LocalFolderItem localFolderItem, FolderItem verFolderItem) { - linkPath = null; + + unregisterLinkFile(); + + contentType = null; icon = null; disabledIcon = null; @@ -151,6 +166,8 @@ public class GhidraFileData { validateCheckout(); boolean fileIDset = updateFileID(); + registerLinkFile(); + if (parent.visited()) { // NOTE: we should maintain some cached data so we can determine if something really changed listener.domainFileStatusChanged(getDomainFile(), fileIDset); @@ -158,7 +175,13 @@ public class GhidraFileData { } private boolean refresh() throws IOException { - linkPath = null; + + unregisterLinkFile(); + + contentType = null; + icon = null; + disabledIcon = null; + String parentPath = parent.getPathname(); if (folderItem == null) { folderItem = fileSystem.getItem(parentPath, name); @@ -183,7 +206,54 @@ public class GhidraFileData { if (folderItem == null && versionedFolderItem == null) { throw new FileNotFoundException(name + " not found"); } - return updateFileID(); + + boolean fileIDset = updateFileID(); + + registerLinkFile(); + + return fileIDset; + } + + private void registerLinkFile() { + try { + ContentHandler contentHandler = getContentHandler(); + isLink = LinkHandler.class.isAssignableFrom(contentHandler.getClass()); + if (!isLink) { + return; + } + isFolderLink = + FolderLinkContentHandler.FOLDER_LINK_CONTENT_TYPE.equals(getContentType()); + + getLinkPath(true); // will cache linkPath and absoluteLinkPath + + if (absoluteLinkPath == null) { + return; + } + + // Avoid registering circular reference + if (isFolderLink && getPathname().startsWith(absoluteLinkPath)) { + return; + } + + RootGhidraFolderData rootFolderData = projectData.getRootFolderData(); + rootFolderData.registerInternalLinkPath(absoluteLinkPath); + } + catch (IOException e) { + // Too much noise if we report IOExceptions. If it happens to one file it could happen + // with many files. + return; + } + } + + private void unregisterLinkFile() { + if (absoluteLinkPath != null) { + RootGhidraFolderData rootFolderData = projectData.getRootFolderData(); + rootFolderData.unregisterInternalLinkPath(absoluteLinkPath); + } + linkPath = null; + absoluteLinkPath = null; + isLink = false; + isFolderLink = false; } private boolean updateFileID() { @@ -204,8 +274,6 @@ public class GhidraFileData { if (mergeInProgress) { return; } - icon = null; - disabledIcon = null; fileIDset |= refresh(); if (parent.visited()) { // NOTE: we should maintain some cached data so we can determine if something really changed @@ -267,6 +335,7 @@ public class GhidraFileData { */ void dispose() { projectData.removeFromIndex(fileID); + unregisterLinkFile(); // NOTE: clearing the following can cause issues since there may be some residual // activity/use which will get a NPE // parent = null; @@ -434,10 +503,6 @@ public class GhidraFileData { } } - boolean isFolderLink() { - return FolderLinkContentHandler.FOLDER_LINK_CONTENT_TYPE.equals(getContentType()); - } - /** * Returns content-type string for this file * @return the file content type or a reserved content type {@link ContentHandler#MISSING_CONTENT} @@ -445,14 +510,22 @@ public class GhidraFileData { */ String getContentType() { synchronized (fileSystem) { - FolderItem item = getFolderItem(DomainFile.DEFAULT_VERSION); - // this can happen when we are trying to load a version file from - // a server to which we are not connected - if (item == null) { - return ContentHandler.MISSING_CONTENT; + if (contentType != null) { + return contentType; } - String contentType = item.getContentType(); - return contentType != null ? contentType : ContentHandler.UNKNOWN_CONTENT; + FolderItem item = getFolderItem(DomainFile.DEFAULT_VERSION); + if (item == null) { + // This can happen when we are trying to load a version file from + // a server to which we are not connected + contentType = ContentHandler.MISSING_CONTENT; + } + else { + contentType = item.getContentType(); + if (contentType == null) { + contentType = ContentHandler.UNKNOWN_CONTENT; + } + } + return contentType; } } @@ -1235,7 +1308,7 @@ public class GhidraFileData { else if (folderItem instanceof TextDataItem textDataItem) { versionedFileSystem.createTextDataItem(parentPath, name, folderItem.getFileID(), folderItem.getContentType(), - textDataItem.getTextData(), comment); + textDataItem.getTextData(), comment, user); } else { throw new IOException( @@ -2237,17 +2310,26 @@ public class GhidraFileData { * @return true if link file else false for a normal domain file */ boolean isLink() { - try { - return LinkHandler.class.isAssignableFrom(getContentHandler().getClass()); - } - catch (IOException e) { - return false; - } + return isLink; // relies on refresh to initialize + } + + /** + * Determine if this file is a link file which corresponds to a folder link. + * If this is a folder-link it should not be used to obtain a {@link DomainObject}. + * The link path or URL stored within the link-file may be read using {@link #getLinkPath(boolean)}. + * The content type (see {@link #getContentType()} of a folder-link will be + * {@link FolderLinkContentHandler}. + * @return true if link file else false for a normal domain file + */ + boolean isFolderLink() { + return isFolderLink; // relies on refresh to initialize } /** * If this is a {@link #isLink() link file} this method will return the link-path which * may be either an absolute or relative path within the the project or a Ghidra URL. + * Invoking with {@code resolve==true} will ensure that both {@code linkPath} and + * {@code absoluteLinkPath} get properly cached. * * @param resolve if true relative paths will always be converted to an absolute path * @return associated link path or null if not a link file @@ -2275,12 +2357,19 @@ public class GhidraFileData { return linkPath; } - String path = linkPath; if (!GhidraURL.isGhidraURL(linkPath)) { - path = getAbsolutePath(linkPath); + if (absoluteLinkPath == null) { + try { + absoluteLinkPath = getAbsolutePath(linkPath); + } + catch (IllegalArgumentException e) { + return null; + } + } + return absoluteLinkPath; } - return path; + return linkPath; } private String getAbsolutePath(String path) throws IOException { @@ -2292,13 +2381,7 @@ public class GhidraFileData { } absPath += path; } - try { - absPath = FileSystem.normalizePath(absPath); - } - catch (IllegalArgumentException e) { - throw new IOException("Invalid link path: " + linkPath); - } - return absPath; + return FileSystem.normalizePath(absPath); } /** @@ -2396,7 +2479,7 @@ public class GhidraFileData { if (!StringUtils.isBlank(lp)) { newParent.getLocalFileSystem() .createTextDataItem(pathname, targetName, - FileIDFactory.createFileID(), contentType, lp, null); + FileIDFactory.createFileID(), contentType, lp, null, null); } else { throw new IOException( diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/GhidraFolderData.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/GhidraFolderData.java index 03c7736d37..b23e02aa08 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/GhidraFolderData.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/GhidraFolderData.java @@ -64,7 +64,7 @@ class GhidraFolderData { // folderList and fileList are only be used if visited is true private Set folderList = new TreeSet<>(); - private boolean visited; // true if full refresh was performed + private boolean visited; // true if full refresh was performed and change notifications get sent private Map fileDataCache = new HashMap<>(); private Map folderDataCache = new HashMap<>(); @@ -122,6 +122,15 @@ class GhidraFolderData { return visited; } + /** + * @return true if this folder must be visited when created to ensure that related change + * notifications are properly conveyed. + */ + boolean mustVisit() { + RootGhidraFolderData rootFolderData = projectData.getRootFolderData(); + return rootFolderData.mustVisit(getPathname()); + } + /** * @return local file system */ @@ -283,7 +292,7 @@ class GhidraFolderData { parent.folderList.remove(oldName); parent.folderList.add(newName); - // Must force refresh to ensure that all folder items are properly updted with new parent path + // Must force refresh to ensure that all folder items are properly updated with new parent path refresh(true, true, projectData.getProjectDisposalMonitor()); listener.domainFolderRenamed(newFolder, oldName); @@ -385,17 +394,14 @@ class GhidraFolderData { * @param newFileName file name after rename */ void fileRenamed(String oldFileName, String newFileName) { - GhidraFileData fileData; - synchronized (fileSystem) { - fileData = fileDataCache.remove(oldFileName); - if (fileData == null || this != fileData.getParent() || - !newFileName.equals(fileData.getName())) { - throw new AssertException(); - } - fileDataCache.put(newFileName, fileData); - if (visited) { - listener.domainFileRenamed(getDomainFile(newFileName), oldFileName); - } + GhidraFileData fileData = fileDataCache.remove(oldFileName); + if (fileData == null || this != fileData.getParent() || + !newFileName.equals(fileData.getName())) { + throw new AssertException(); + } + fileDataCache.put(newFileName, fileData); + if (visited) { + listener.domainFileRenamed(getDomainFile(newFileName), oldFileName); } } @@ -510,15 +516,13 @@ class GhidraFolderData { * if this folder has been visited * @param folderName name of folder which was removed */ - void folderRemoved(String folderName) { - synchronized (fileSystem) { - GhidraFolderData folderData = folderDataCache.remove(folderName); - if (folderData != null) { - folderData.dispose(); - } - if (visited && folderList.remove(folderName)) { - listener.domainFolderRemoved(getDomainFolder(), folderName); - } + private void folderRemoved(String folderName) { + GhidraFolderData folderData = folderDataCache.remove(folderName); + if (folderData != null) { + folderData.dispose(); + } + if (visited && folderList.remove(folderName)) { + listener.domainFolderRemoved(getDomainFolder(), folderName); } } @@ -561,6 +565,8 @@ class GhidraFolderData { */ private void refreshFolders(boolean recursive, TaskMonitor monitor) throws IOException { + // FIXME: inconsistent use of forced-recursive refresh and cached folderList + String path = getPathname(); HashSet newSet = new HashSet<>(); @@ -772,6 +778,10 @@ class GhidraFolderData { void refresh(boolean recursive, boolean force, TaskMonitor monitor) throws IOException { synchronized (fileSystem) { if (recursive && !force) { + +// FIXME: Why must this restriction be imposed. We need a lazy refresh that only refreshes +// those folders that have been visited or must be visited. + throw new IllegalArgumentException("force must be true when recursive"); } if (monitor != null && monitor.isCancelled()) { @@ -780,6 +790,9 @@ class GhidraFolderData { if (visited && !force) { return; } + + visited = true; + try { updateExistenceState(); } @@ -797,18 +810,15 @@ class GhidraFolderData { throw new FileNotFoundException("Folder not found: " + getPathname()); } - try { - refreshFiles(monitor); + // FIXME: If forced we should be refreshing folder/file lists - if (monitor != null && monitor.isCancelled()) { - return; // break-out from recursion on cancel - } + refreshFiles(monitor); - refreshFolders(recursive, monitor); - } - finally { - visited = true; + if (monitor != null && monitor.isCancelled()) { + return; // break-out from recursion on cancel } + + refreshFolders(recursive, monitor); } } @@ -843,8 +853,11 @@ class GhidraFolderData { try { folderData = new GhidraFolderData(this, folderName); folderDataCache.put(folderName, folderData); + if (folderData.mustVisit()) { + folderData.refresh(false, true, TaskMonitor.DUMMY); + } } - catch (FileNotFoundException e) { + catch (IOException e) { // ignore } } @@ -1226,6 +1239,10 @@ class GhidraFolderData { GhidraFolder newFolder = getDomainFolder(); if (parent.visited || newParent.visited) { + + // Must force refresh to ensure that all folder items are properly updated with new parent path + refresh(true, true, projectData.getProjectDisposalMonitor()); + listener.domainFolderMoved(newFolder, oldParent); } @@ -1398,17 +1415,34 @@ class GhidraFolderData { String linkFilename, LinkHandler lh) throws IOException { synchronized (fileSystem) { if (fileSystem.isReadOnly()) { - throw new ReadOnlyException("copyAsLink permitted to writeable project only"); + throw new ReadOnlyException("createLinkFile permitted to writeable project only"); } + boolean referenceMyProject = (sourceProjectData == projectData); + boolean isFolderLink = (lh instanceof FolderLinkContentHandler); + if (!pathname.startsWith(FileSystem.SEPARATOR)) { - throw new IllegalArgumentException("invalid pathname specified"); + throw new IllegalArgumentException( + "invalid absolute pathname specified: " + pathname); } + if (isFolderLink) { + // Force folder link path to end with "/" for GhidraURL case to disambiguate + if (!referenceMyProject && !pathname.endsWith(FileSystem.SEPARATOR)) { + pathname += FileSystem.SEPARATOR; + } + } + else if (pathname.endsWith(FileSystem.SEPARATOR) || pathname.endsWith("/.") || + pathname.endsWith("/..")) { + throw new IllegalArgumentException("invalid file pathname specified: " + pathname); + } + + pathname = FileSystem.normalizePath(pathname); + String linkPath; - if (sourceProjectData == projectData) { + if (referenceMyProject) { if (makeRelative) { - linkPath = getRelativePath(pathname, getPathname()); + linkPath = getRelativePath(pathname, getPathname(), isFolderLink); } else { linkPath = pathname; @@ -1495,12 +1529,48 @@ class GhidraFolderData { } } - private static String getRelativePath(String referencedPathname, String linkParentPathname) { - Path referencedPath = Paths.get(referencedPathname); + /** + * + * @param normalizedReferencedPathname an absolute normalized folder/file reference path + * (see {@link FileSystem#normalizePath(String)}). + * @param linkParentPathname an absolute Ghidra folder pathname which will be the origin + * of the returned relative path and will be the folder where the lin-file is stored. + * @param isFolderRef true if {@code normalizedReferencedPathname} refers to a folder, else false + * @return relative path + * @throws IllegalArgumentException if referenced path cannot be relativized. This should not + * occur if absolute normalized path arguments are properly formed and are legal. + */ + static String getRelativePath(String normalizedReferencedPathname, String linkParentPathname, + boolean isFolderRef) throws IllegalArgumentException { + + String finalRefElement = null; + if (!isFolderRef && !normalizedReferencedPathname.endsWith(FileSystem.SEPARATOR)) { + // Preserve last element name which may not be a folder name if not within root folder + int lastSepIx = normalizedReferencedPathname.lastIndexOf(FileSystem.SEPARATOR); + if (lastSepIx != 0) { + finalRefElement = normalizedReferencedPathname.substring(lastSepIx + 1); + normalizedReferencedPathname = normalizedReferencedPathname.substring(0, lastSepIx); + } + } + + Path referencedPath = Paths.get(normalizedReferencedPathname); Path linkParentPath = Paths.get(linkParentPathname); Path relativePath = linkParentPath.relativize(referencedPath); String path = relativePath.toString(); - if (referencedPathname.endsWith(FileSystem.SEPARATOR) && + + // Re-apply preserved finalRefElement to relative path + if (finalRefElement != null) { + if (!path.isBlank()) { + path += FileSystem.SEPARATOR; + } + path += finalRefElement; + } + + if (path.isBlank()) { + return "."; + } + + if (normalizedReferencedPathname.endsWith(FileSystem.SEPARATOR) && !path.endsWith(FileSystem.SEPARATOR)) { path += FileSystem.SEPARATOR; } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkHandler.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkHandler.java index 5c321bfc0a..2ed40febef 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkHandler.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkHandler.java @@ -103,7 +103,7 @@ public abstract class LinkHandler implements Co String linkFilename) throws IOException, InvalidNameException { fs.createTextDataItem(folderPath, linkFilename, FileIDFactory.createFileID(), - getContentType(), linkPath, null); + getContentType(), linkPath, null, null); } @Override @@ -334,9 +334,20 @@ public abstract class LinkHandler implements Co } String path = linkPath; + boolean isRelative = false; if (!GhidraURL.isGhidraURL(path)) { if (!linkPath.startsWith(FileSystem.SEPARATOR)) { - path = linkFile.getParent().getPathname(); + isRelative = true; + DomainFolder parent; + if (linkFile instanceof LinkedDomainFile linkedFile) { + // Relative to real file's parent + parent = linkedFile.getRealFile().getParent(); + } + else { + // Relative to link-file's parent + parent = linkFile.getParent(); + } + path = parent.getPathname(); if (!path.endsWith(FileSystem.SEPARATOR)) { path += FileSystem.SEPARATOR; } @@ -346,7 +357,11 @@ public abstract class LinkHandler implements Co return FileSystem.normalizePath(path); } catch (IllegalArgumentException e) { - throw new IOException("Invalid link path: " + linkPath); + String hint = ""; + if (isRelative && linkFile instanceof LinkedDomainFile) { + hint = " (relative to real link-file)"; + } + throw new IOException("Invalid link path: " + path + hint); } } return path; @@ -369,12 +384,17 @@ public abstract class LinkHandler implements Co /** * Add real internal folder path for specified folder or folder-link and check for - * circular conflict. + * circular conflict. + * + * NOTE: This is only useful in detecting a self-referencing + * path and not those that involve multiple independent linked-folders that could + * form circular paths. + * * @param pathSet real path accumulator * @param linkPath internal linkPath * @return true if no path conflict detected, false if path conflict is detected */ - private static boolean addLinkPathPath(Set pathSet, String linkPath) { + private static boolean addLinkPath(Set pathSet, String linkPath) { // Must ensure that all paths end with '/' separator - even if path is endpoint if (!linkPath.endsWith(FileSystem.SEPARATOR)) { linkPath += FileSystem.SEPARATOR; @@ -418,7 +438,7 @@ public abstract class LinkHandler implements Co if (parent instanceof LinkedDomainFolder lf) { try { projectData = lf.getLinkedProjectData(); - addLinkPathPath(linkPathsVisited, lf.getLinkedPathname()); + addLinkPath(linkPathsVisited, lf.getLinkedPathname()); } catch (IOException e) { throw new RuntimeException("Unexpected", e); @@ -426,7 +446,7 @@ public abstract class LinkHandler implements Co } else { projectData = parent.getProjectData(); - addLinkPathPath(linkPathsVisited, file.getPathname()); + addLinkPath(linkPathsVisited, file.getPathname()); } String contentType = file.getContentType(); @@ -466,32 +486,34 @@ public abstract class LinkHandler implements Co return nextLinkFile; } - if (!addLinkPathPath(linkPathsVisited, linkPath)) { - errorConsumer.accept("Link has a circular reference"); - break; // broken and can't continue - } - DomainFile linkedFile = null; if (!linkPath.endsWith(FileSystem.SEPARATOR)) { linkedFile = projectData.getFile(linkPath); } + DomainFolder linkedFolder = null; if (isFolderLink) { - // Check for folder existence at linkPath - if (getNonLinkedFolder(projectData, linkPath) != null) { - // Check for folder-link that conflicts with folder found - if (linkedFile != null) { - LinkFileInfo linkedFileLinkInfo = linkedFile.getLinkInfo(); - if (linkedFileLinkInfo != null && linkedFileLinkInfo.isFolderLink()) { - errorConsumer.accept( - "Referenced folder name conflicts with folder-link in the same folder: " + - linkPath); - break; - } + linkedFolder = getNonLinkedFolder(projectData, linkPath); + } + + if (linkedFolder == null && !addLinkPath(linkPathsVisited, linkPath)) { + errorConsumer.accept("Link has a circular reference"); + break; // broken and can't continue + } + + if (isFolderLink && linkedFolder != null) { + // Check for folder-link that conflicts with folder found + if (linkedFile != null) { + LinkFileInfo linkedFileLinkInfo = linkedFile.getLinkInfo(); + if (linkedFileLinkInfo != null && linkedFileLinkInfo.isFolderLink()) { + errorConsumer.accept( + "Referenced folder name conflicts with folder-link in the same folder: " + + linkPath); + break; } - statusConsumer.accept(LinkStatus.INTERNAL); - return nextLinkFile; } + statusConsumer.accept(LinkStatus.INTERNAL); + return nextLinkFile; } if (linkedFile == null) { diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkedGhidraFile.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkedGhidraFile.java index aa17e5d2f1..d7b2511231 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkedGhidraFile.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkedGhidraFile.java @@ -43,21 +43,21 @@ class LinkedGhidraFile implements LinkedDomainFile { private final LinkedGhidraSubFolder parent; private final String fileName; + private final DomainFile realDomainFile; + private final LinkFileInfo linkInfo; - LinkedGhidraFile(LinkedGhidraSubFolder parent, String fileName) { + LinkedGhidraFile(LinkedGhidraSubFolder parent, DomainFile realDomainFile) { this.parent = parent; - this.fileName = fileName; + this.fileName = realDomainFile.getName(); + this.realDomainFile = realDomainFile; + this.linkInfo = realDomainFile.isLink() ? new LinkedFileLinkInfo() : null; } @Override - public DomainFile getLinkedFile() throws IOException { + public DomainFile getRealFile() throws IOException { return parent.getLinkedFile(fileName); } - private DomainFile getLinkedFileNoError() { - return parent.getLinkedFileNoError(fileName); - } - @Override public DomainFolder getParent() { return parent; @@ -94,18 +94,18 @@ class LinkedGhidraFile implements LinkedDomainFile { @Override public boolean exists() { - return getLinkedFileNoError() != null; + DomainFile df = parent.getLinkedFileNoError(fileName); + return df != null && df.exists(); } @Override public String getFileID() { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.getFileID() : null; + return realDomainFile.getFileID(); } @Override public DomainFile setName(String newName) throws InvalidNameException, IOException { - String name = getLinkedFile().setName(newName).getName(); + String name = getRealFile().setName(newName).getName(); return parent.getFile(name); } @@ -156,46 +156,40 @@ class LinkedGhidraFile implements LinkedDomainFile { @Override public String getContentType() { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.getContentType() : ContentHandler.UNKNOWN_CONTENT; + return realDomainFile.getContentType(); } @Override public Class getDomainObjectClass() { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.getDomainObjectClass() : DomainObject.class; + return realDomainFile.getDomainObjectClass(); } @Override public ChangeSet getChangesByOthersSinceCheckout() throws VersionException, IOException { - return getLinkedFile().getChangesByOthersSinceCheckout(); + return getRealFile().getChangesByOthersSinceCheckout(); } @Override public DomainObject getDomainObject(Object consumer, boolean okToUpgrade, boolean okToRecover, TaskMonitor monitor) throws VersionException, IOException, CancelledException { - return getLinkedFile().getDomainObject(consumer, okToUpgrade, okToRecover, monitor); + return getRealFile().getDomainObject(consumer, okToUpgrade, okToRecover, monitor); } @Override public DomainObject getOpenedDomainObject(Object consumer) { - DomainFile df = getLinkedFileNoError(); - if (df != null) { - return df.getOpenedDomainObject(consumer); - } - return null; + return realDomainFile.getOpenedDomainObject(consumer); } @Override public DomainObject getReadOnlyDomainObject(Object consumer, int version, TaskMonitor monitor) throws VersionException, IOException, CancelledException { - return getLinkedFile().getReadOnlyDomainObject(consumer, version, monitor); + return getRealFile().getReadOnlyDomainObject(consumer, version, monitor); } @Override public DomainObject getImmutableDomainObject(Object consumer, int version, TaskMonitor monitor) throws VersionException, IOException, CancelledException { - return getLinkedFile().getImmutableDomainObject(consumer, version, monitor); + return getRealFile().getImmutableDomainObject(consumer, version, monitor); } @Override @@ -226,191 +220,174 @@ class LinkedGhidraFile implements LinkedDomainFile { @Override public long getLastModifiedTime() { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.getLastModifiedTime() : 0; + return realDomainFile.getLastModifiedTime(); } @Override public Icon getIcon(boolean disabled) { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.getIcon(disabled) : UNSUPPORTED_FILE_ICON; + return realDomainFile.getIcon(disabled); } @Override public boolean isCheckedOut() { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.isCheckedOut() : false; + return realDomainFile.isCheckedOut(); } @Override public boolean isCheckedOutExclusive() { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.isCheckedOutExclusive() : false; + return realDomainFile.isCheckedOutExclusive(); } @Override public boolean modifiedSinceCheckout() { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.modifiedSinceCheckout() : false; + return realDomainFile.modifiedSinceCheckout(); } @Override public boolean canCheckout() { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.canCheckout() : false; + return realDomainFile.canCheckout(); } @Override public boolean canCheckin() { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.canCheckin() : false; + return realDomainFile.canCheckin(); } @Override public boolean canMerge() { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.canMerge() : false; + return realDomainFile.canMerge(); } @Override public boolean canAddToRepository() { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.canAddToRepository() : false; + return realDomainFile.canAddToRepository(); } @Override public void setReadOnly(boolean state) throws IOException { - getLinkedFile().setReadOnly(state); + getRealFile().setReadOnly(state); } @Override public boolean isReadOnly() { - DomainFile df = getLinkedFileNoError(); - // read-only state not reflected by icon - return df != null ? df.isReadOnly() : true; + return realDomainFile.isReadOnly(); } @Override public boolean isVersioned() { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.isVersioned() : false; + return realDomainFile.isVersioned(); } @Override public boolean isHijacked() { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.isHijacked() : false; + return realDomainFile.isHijacked(); } @Override public int getLatestVersion() { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.getLatestVersion() : DomainFile.DEFAULT_VERSION; + return realDomainFile.getLatestVersion(); } @Override public boolean isLatestVersion() { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.isLatestVersion() : true; + return realDomainFile.isLatestVersion(); } @Override public int getVersion() { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.getVersion() : DomainFile.DEFAULT_VERSION; + return realDomainFile.getVersion(); } @Override public Version[] getVersionHistory() throws IOException { - DomainFile df = getLinkedFile(); + DomainFile df = getRealFile(); return df != null ? df.getVersionHistory() : new Version[0]; } @Override public void addToVersionControl(String comment, boolean keepCheckedOut, TaskMonitor monitor) throws IOException, CancelledException { - getLinkedFile().addToVersionControl(comment, keepCheckedOut, monitor); + getRealFile().addToVersionControl(comment, keepCheckedOut, monitor); } @Override public boolean checkout(boolean exclusive, TaskMonitor monitor) throws IOException, CancelledException { - return getLinkedFile().checkout(exclusive, monitor); + return getRealFile().checkout(exclusive, monitor); } @Override public void checkin(CheckinHandler checkinHandler, TaskMonitor monitor) throws IOException, VersionException, CancelledException { - getLinkedFile().checkin(checkinHandler, monitor); + getRealFile().checkin(checkinHandler, monitor); } @Override public void merge(boolean okToUpgrade, TaskMonitor monitor) throws IOException, VersionException, CancelledException { - getLinkedFile().merge(okToUpgrade, monitor); + getRealFile().merge(okToUpgrade, monitor); } @Override public void undoCheckout(boolean keep) throws IOException { - getLinkedFile().undoCheckout(keep); + getRealFile().undoCheckout(keep); } @Override public void undoCheckout(boolean keep, boolean force) throws IOException { - getLinkedFile().undoCheckout(keep, force); + getRealFile().undoCheckout(keep, force); } @Override public void terminateCheckout(long checkoutId) throws IOException { - getLinkedFile().terminateCheckout(checkoutId); + getRealFile().terminateCheckout(checkoutId); } @Override public ItemCheckoutStatus[] getCheckouts() throws IOException { - return getLinkedFile().getCheckouts(); + return getRealFile().getCheckouts(); } @Override public ItemCheckoutStatus getCheckoutStatus() throws IOException { - return getLinkedFile().getCheckoutStatus(); + return getRealFile().getCheckoutStatus(); } @Override public void delete() throws IOException { - getLinkedFile().delete(); + getRealFile().delete(); } @Override public void delete(int version) throws IOException { - getLinkedFile().delete(version); + getRealFile().delete(version); } @Override public DomainFile moveTo(DomainFolder newParent) throws IOException { - return getLinkedFile().moveTo(newParent); + return getRealFile().moveTo(newParent); } @Override public DomainFile copyTo(DomainFolder newParent, TaskMonitor monitor) throws IOException, CancelledException { - return getLinkedFile().copyTo(newParent, monitor); + return getRealFile().copyTo(newParent, monitor); } @Override public DomainFile copyVersionTo(int version, DomainFolder destFolder, TaskMonitor monitor) throws IOException, CancelledException { - return getLinkedFile().copyVersionTo(version, destFolder, monitor); + return getRealFile().copyVersionTo(version, destFolder, monitor); } @Override public DomainFile copyToAsLink(DomainFolder newParent, boolean relative) throws IOException { - return getLinkedFile().copyToAsLink(newParent, relative); + return getRealFile().copyToAsLink(newParent, relative); } @Override public boolean isLinkingSupported() { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.isLinkingSupported() : false; + return realDomainFile.isLinkingSupported(); } @Override @@ -420,8 +397,7 @@ class LinkedGhidraFile implements LinkedDomainFile { @Override public boolean isChanged() { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.isChanged() : false; + return realDomainFile.isChanged(); } @Override @@ -436,31 +412,57 @@ class LinkedGhidraFile implements LinkedDomainFile { @Override public void packFile(File file, TaskMonitor monitor) throws IOException, CancelledException { - getLinkedFile().packFile(file, monitor); + getRealFile().packFile(file, monitor); } @Override public Map getMetadata() { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.getMetadata() : Map.of(); + return realDomainFile.getMetadata(); } @Override public long length() throws IOException { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.length() : 0; + return realDomainFile.length(); } @Override public boolean isLink() { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.isLink() : false; + return linkInfo != null; } @Override public LinkFileInfo getLinkInfo() { - DomainFile df = getLinkedFileNoError(); - return df != null ? df.getLinkInfo() : null; + return linkInfo; + } + + private class LinkedFileLinkInfo implements LinkFileInfo { + + @Override + public DomainFile getFile() { + return LinkedGhidraFile.this; + } + + @Override + public LinkedGhidraFolder getLinkedFolder() { + try { + return FolderLinkContentHandler.getLinkedFolder(LinkedGhidraFile.this); + } + catch (IOException e) { + // Ignore + } + return null; + } + + @Override + public String getLinkPath() { + return realDomainFile.getLinkInfo().getLinkPath(); + } + + @Override + public String getAbsoluteLinkPath() throws IOException { + return realDomainFile.getLinkInfo().getAbsoluteLinkPath(); + } + } @Override @@ -470,12 +472,7 @@ class LinkedGhidraFile implements LinkedDomainFile { @Override public String toString() { - String str = parent.toString(); - if (!str.endsWith("/")) { - str += "/"; - } - str += getName(); - return str; + return getPathname() + "->" + realDomainFile.getPathname(); } } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkedGhidraFolder.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkedGhidraFolder.java index d99d53830d..955d49b11a 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkedGhidraFolder.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkedGhidraFolder.java @@ -57,7 +57,8 @@ public class LinkedGhidraFolder extends LinkedGhidraSubFolder { * since an error will occur if there is no active project at the time the link is followed. * * @param folderLinkFile link-file which corresponds to a linked-folder - * (see {@link LinkFileInfo#isFolderLink()}). + * (see {@link LinkFileInfo#isFolderLink()}). This file should used to establish the parent + * of this linked-folder. * @param linkedFolderUrl linked folder URL */ LinkedGhidraFolder(DomainFile folderLinkFile, URL linkedFolderUrl) { @@ -87,7 +88,8 @@ public class LinkedGhidraFolder extends LinkedGhidraSubFolder { * {@link #getProjectData() project data} instance. * * @param folderLinkFile link-file which corresponds to a linked-folder - * (see {@link LinkFileInfo#isFolderLink()}). + * (see {@link LinkFileInfo#isFolderLink()}). This file should used to establish the parent + * of this linked-folder. * @param linkedFolder locally-linked folder within same project */ LinkedGhidraFolder(DomainFile folderLinkFile, DomainFolder linkedFolder) { @@ -114,7 +116,7 @@ public class LinkedGhidraFolder extends LinkedGhidraSubFolder { if (!(obj instanceof LinkedGhidraFolder other)) { return false; } - return linkedPathname.equals(other.linkedPathname) && + return getPathname().equals(other.getPathname()) && folderLinkFile.equals(other.folderLinkFile); } @@ -238,9 +240,9 @@ public class LinkedGhidraFolder extends LinkedGhidraSubFolder { @Override public String toString() { if (linkedFolder != null) { - return "->" + getLinkedPathname(); + return getPathname() + "->" + getLinkedPathname(); } - return "->" + linkedFolderUrl.toString(); + return getPathname() + "->" + linkedFolderUrl.toString(); } @Override diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkedGhidraSubFolder.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkedGhidraSubFolder.java index 24d5813916..d2274b35ac 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkedGhidraSubFolder.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkedGhidraSubFolder.java @@ -274,7 +274,7 @@ class LinkedGhidraSubFolder implements LinkedDomainFolder { DomainFile[] files = linkedFolder.getFiles(); LinkedGhidraFile[] linkedSubFolders = new LinkedGhidraFile[files.length]; for (int i = 0; i < files.length; i++) { - linkedSubFolders[i] = new LinkedGhidraFile(this, files[i].getName()); + linkedSubFolders[i] = new LinkedGhidraFile(this, files[i]); } return linkedSubFolders; } @@ -286,6 +286,10 @@ class LinkedGhidraSubFolder implements LinkedDomainFolder { /** * Get the true file within this linked folder. + *

+ * NOTE: The returned file is the "real" file and would be the have the equivalence: + * {@code folder.getLinkedFileNoError("X") == folder.getFile("X").getRealFile() }. + * * @param name file name * @return file or null if not found or error occurs */ @@ -300,6 +304,16 @@ class LinkedGhidraSubFolder implements LinkedDomainFolder { return null; } + /** + * Get the true file within this linked folder. + *

+ * NOTE: The returned file is the "real" file and would be the have the equivalence: + * {@code folder.getLinkedFile("X") == folder.getFile("X").getRealFile() }. + * + * @param name file name + * @return file or null if not found or error occurs + * @throws IOException if an IO error ocurs such as FileNotFoundException + */ DomainFile getLinkedFile(String name) throws IOException { DomainFolder linkedFolder = getRealFolder(); DomainFile df = linkedFolder.getFile(name); @@ -311,8 +325,8 @@ class LinkedGhidraSubFolder implements LinkedDomainFolder { @Override public DomainFile getFile(String name) { - DomainFile f = getLinkedFileNoError(name); - return f != null ? new LinkedGhidraFile(this, name) : null; + DomainFile file = getLinkedFileNoError(name); + return file != null ? new LinkedGhidraFile(this, file) : null; } @Override @@ -333,36 +347,40 @@ class LinkedGhidraSubFolder implements LinkedDomainFolder { public DomainFile createFile(String name, DomainObject obj, TaskMonitor monitor) throws InvalidNameException, IOException, CancelledException { DomainFolder linkedFolder = getRealFolder(); - return linkedFolder.createFile(name, obj, monitor); + DomainFile file = linkedFolder.createFile(name, obj, monitor); + return getFile(file.getName()); } @Override public DomainFile createFile(String name, File packFile, TaskMonitor monitor) throws InvalidNameException, IOException, CancelledException { DomainFolder linkedFolder = getRealFolder(); - return linkedFolder.createFile(name, packFile, monitor); + DomainFile file = linkedFolder.createFile(name, packFile, monitor); + return getFile(file.getName()); } @Override public DomainFile createLinkFile(ProjectData sourceProjectData, String pathname, boolean makeRelative, String linkFilename, LinkHandler lh) throws IOException { DomainFolder linkedFolder = getRealFolder(); - return linkedFolder.createLinkFile(sourceProjectData, pathname, makeRelative, linkFilename, - lh); + DomainFile file = linkedFolder.createLinkFile(sourceProjectData, pathname, makeRelative, + linkFilename, lh); + return getFile(file.getName()); } @Override public DomainFile createLinkFile(String ghidraUrl, String linkFilename, LinkHandler lh) throws IOException { DomainFolder linkedFolder = getRealFolder(); - return linkedFolder.createLinkFile(ghidraUrl, linkFilename, lh); + DomainFile file = linkedFolder.createLinkFile(ghidraUrl, linkFilename, lh); + return getFile(file.getName()); } @Override public DomainFolder createFolder(String name) throws InvalidNameException, IOException { DomainFolder linkedFolder = getRealFolder(); - DomainFolder child = linkedFolder.createFolder(name); - return new LinkedGhidraSubFolder(parent, child.getName()); + DomainFolder folder = linkedFolder.createFolder(name); + return getFolder(folder.getName()); } @Override diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/RootGhidraFolderData.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/RootGhidraFolderData.java index dab2dafcdc..3542151fae 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/RootGhidraFolderData.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/RootGhidraFolderData.java @@ -4,9 +4,9 @@ * 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. @@ -15,11 +15,26 @@ */ package ghidra.framework.data; +import java.io.IOException; +import java.util.HashMap; + import ghidra.framework.model.DomainFolderChangeListener; import ghidra.framework.store.FileSystem; +import ghidra.util.task.TaskMonitor; public class RootGhidraFolderData extends GhidraFolderData { + // Folder path reference counts, associated with discovered file-links and folder-links, + // are tracked to ensure that such folders are visited immediately or upon their + // creation to ensure that the folder change listener is properly notified of all changes + // related to the folder paths contained within this map. + private HashMap folderReferenceCounts = new HashMap<>(); + + /** + * Constructor for project data root folder. + * @param projectData project data + * @param listener folder change listener + */ RootGhidraFolderData(DefaultProjectData projectData, DomainFolderChangeListener listener) { super(projectData, listener); } @@ -50,4 +65,97 @@ public class RootGhidraFolderData extends GhidraFolderData { return true; } + /** + * Determine if the specified folder path must be visited due to + * possible link references to the folder or one of its children. + * @param folderPathname folder pathname (not ending with '/') + * @return true if folder should be visited to ensure that changes are properly tracked + * with proper change notifications sent. + */ + public boolean mustVisit(String folderPathname) { + return folderReferenceCounts.containsKey(folderPathname); + } + + /** + * Register internal file/folder-link to ensure we do not ignore change events which affect + * the referenced file/folder. + * @param absoluteLinkPath absolute internal path referenced by a link-file + */ + void registerInternalLinkPath(String absoluteLinkPath) { + if (!absoluteLinkPath.startsWith(FileSystem.SEPARATOR)) { + throw new IllegalArgumentException(); + } + + // Register path elements upto parent of absoluteLinkPath + String[] pathSplit = absoluteLinkPath.split(FileSystem.SEPARATOR); + int folderElementCount = pathSplit.length - 1; + + // Start at 1 since element 0 corresponds to root and will be empty + GhidraFolderData folderData = this; + StringBuilder pathBuilder = new StringBuilder(); + for (int i = 1; i < folderElementCount; i++) { + String folderName = pathSplit[i]; + if (folderName.length() == 0) { + // ignore blank names + continue; + } + if (folderData != null) { + folderData = folderData.getFolderData(folderName, false); + if (folderData != null && !folderData.visited()) { + try { + folderData.refresh(false, true, TaskMonitor.DUMMY); + } + catch (IOException e) { + // ignore - things may get out-of-sync + folderData = null; + } + } + } + + // Increment folder reference count for all folders leading up to referenced folder + pathBuilder.append(FileSystem.SEPARATOR); + pathBuilder.append(folderName); + folderReferenceCounts.compute(pathBuilder.toString(), + (path, count) -> (count == null) ? 1 : ++count); + } + } + + /** + * Unregister internal file/folder-link to ensure we do not ignore change events which affect + * the referenced file/folder. + * @param absoluteLinkPath absolute internal path referenced by a link-file + */ + void unregisterInternalLinkPath(String absoluteLinkPath) { + if (!absoluteLinkPath.startsWith(FileSystem.SEPARATOR)) { + throw new IllegalArgumentException(); + } + + // Register path elements upto parent of absoluteLinkPath + String[] pathSplit = absoluteLinkPath.split(FileSystem.SEPARATOR); + int folderElementCount = pathSplit.length - 1; + + // Start at 1 since element 0 corresponds to root and will be empty + StringBuilder pathBuilder = new StringBuilder(); + for (int i = 1; i < folderElementCount; i++) { + String folderName = pathSplit[i]; + if (folderName.length() == 0) { + // ignore blank names + continue; + } + // Increment folder reference count for all folders leading up to referenced folder + pathBuilder.append(FileSystem.SEPARATOR); + pathBuilder.append(folderName); + String path = pathBuilder.toString(); + Integer count = folderReferenceCounts.get(path); + if (count != null) { + if (count == 1) { + folderReferenceCounts.remove(path); + } + else { + folderReferenceCounts.put(path, count - 1); + } + } + } + } + } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/FrontEndPlugin.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/FrontEndPlugin.java index 0048dd7c8a..1a2ccb7c6c 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/FrontEndPlugin.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/FrontEndPlugin.java @@ -54,6 +54,7 @@ import ghidra.framework.plugintool.util.PluginStatus; import ghidra.framework.preferences.Preferences; import ghidra.framework.protocol.ghidra.GhidraURL; import ghidra.framework.remote.User; +import ghidra.framework.store.FileSystem; import ghidra.util.*; import ghidra.util.filechooser.GhidraFileChooserModel; import ghidra.util.filechooser.GhidraFileFilter; @@ -137,6 +138,7 @@ public class FrontEndPlugin extends Plugin private ProjectDataRenameAction renameAction; private ProjectDataOpenDefaultToolAction openAction; private ProjectDataFollowLinkAction followLinkAction; + private ProjectDataSelectRealFileOrFolderAction selectRealFileOrFolderAction; private ProjectDataExpandAction expandAction; private ProjectDataCollapseAction collapseAction; private ProjectDataSelectAction selectAction; @@ -221,6 +223,7 @@ public class FrontEndPlugin extends Plugin // Top of popup menu actions - no group openAction = new ProjectDataOpenDefaultToolAction(owner, null); followLinkAction = new ProjectDataFollowLinkAction(this, null); + selectRealFileOrFolderAction = new ProjectDataSelectRealFileOrFolderAction(this, null); String groupName = "Cut/copy/paste/new1"; newFolderAction = new FrontEndProjectDataNewFolderAction(owner, groupName); @@ -258,6 +261,7 @@ public class FrontEndPlugin extends Plugin tool.addAction(deleteAction); tool.addAction(openAction); tool.addAction(followLinkAction); + tool.addAction(selectRealFileOrFolderAction); tool.addAction(renameAction); tool.addAction(expandAction); tool.addAction(collapseAction); @@ -1117,9 +1121,15 @@ public class FrontEndPlugin extends Plugin showInViewedProject(LinkHandler.getLinkURL(lastLink), true); } else if (!dataTreePanel.isShowing()) { - // Filter table on absolute link path String linkPath = LinkHandler.getAbsoluteLinkPath(domainFile); - dataTablePanel.setFilter(linkPath); + if (linkPath.startsWith(FileSystem.SEPARATOR) && linkPath.length() > 1) { + // Filter table on absolute internal link path + if (linkPath.endsWith(FileSystem.SEPARATOR)) { + // Remove trailing '/' from path to ensure we match + linkPath = linkPath.substring(0, linkPath.length() - 1); + } + dataTablePanel.setFilter(linkPath); + } } } catch (IOException e) { diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/ProjectDataSelectRealFileOrFolderAction.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/ProjectDataSelectRealFileOrFolderAction.java new file mode 100644 index 0000000000..86c7d93ef1 --- /dev/null +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/ProjectDataSelectRealFileOrFolderAction.java @@ -0,0 +1,103 @@ +/* ### + * 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.main; + +import java.io.IOException; + +import docking.action.MenuData; +import ghidra.framework.main.datatable.FrontendProjectTreeAction; +import ghidra.framework.main.datatable.ProjectDataContext; +import ghidra.framework.main.datatree.DataTree; +import ghidra.framework.model.*; +import ghidra.util.HelpLocation; +import ghidra.util.Msg; + +public class ProjectDataSelectRealFileOrFolderAction extends FrontendProjectTreeAction { + + private FrontEndPlugin plugin; + + public ProjectDataSelectRealFileOrFolderAction(FrontEndPlugin plugin, String group) { + super("Select Real File or Folder", plugin.getName()); + this.plugin = plugin; + setPopupMenuData(new MenuData(new String[] { "Select Real File" }, group)); + setHelpLocation(new HelpLocation("FrontEndPlugin", "Select_Real_File_or_Folder")); + } + + @Override + protected void actionPerformed(ProjectDataContext context) { + + boolean isFolder = false; + String pathname; + + try { + if (context.getFolderCount() == 1 && context.getFileCount() == 0) { + DomainFolder folder = context.getSelectedFolders().get(0); + if (!(folder instanceof LinkedDomainFolder linkedFolder)) { + return; + } + isFolder = true; + pathname = linkedFolder.getRealFolder().getPathname(); + } + else if (context.getFileCount() == 1 && context.getFolderCount() == 0) { + DomainFile file = context.getSelectedFiles().get(0); + if (!(file instanceof LinkedDomainFile linkedFile)) { + return; + } + isFolder = false; + pathname = linkedFile.getRealFile().getPathname(); + } + else { + return; + } + + // Path is local to its project data tree + plugin.showInProjectTree(context.getProjectData(), pathname, isFolder); + } + catch (IOException e) { + Msg.showError(this, null, "Linked Content Error", + "Failed to resolve linked " + (isFolder ? "folder" : "file"), e); + return; + } + + } + + @Override + protected boolean isEnabledForContext(ProjectDataContext context) { + boolean enabled = false; + String contentType = "Content"; + if (context.getComponent() instanceof DataTree) { + if (context.getFolderCount() == 1 && context.getFileCount() == 0) { + DomainFolder folder = context.getSelectedFolders().get(0); + if (folder instanceof LinkedDomainFolder) { + contentType = "Folder"; + enabled = true; + } + } + else if (context.getFileCount() == 1 && context.getFolderCount() == 0) { + DomainFile file = context.getSelectedFiles().get(0); + if (file instanceof LinkedDomainFile) { + contentType = "File"; + enabled = true; + } + } + } + if (enabled) { + setPopupMenuData(new MenuData(new String[] { "Select Real " + contentType }, + getPopupMenuData().getMenuGroup())); + } + return enabled; + } +} diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/ChangeManager.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/ChangeManager.java index f3b5199ecd..60825733d1 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/ChangeManager.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/ChangeManager.java @@ -27,6 +27,9 @@ import docking.widgets.tree.GTreeNode; import ghidra.framework.data.LinkHandler; import ghidra.framework.main.datatree.DataTreeNode.NodeType; import ghidra.framework.model.*; +import ghidra.framework.store.local.LocalFileSystem; +import ghidra.util.Msg; +import ghidra.util.Swing; /** * Class to handle changes when a domain folder changes; updates the @@ -49,6 +52,10 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { private boolean skipLinkUpdate = false; // updates within Swing event dispatch thread only + // The refreshedTrackingSet is used to track recursive path refreshes to avoid infinite + // recursion. See updateLinkedContent and LinkedTreeNode.refreshLinks methods. + private HashSet refreshedTrackingSet; + ChangeManager(ProjectDataTreePanel treePanel) { this.treePanel = treePanel; projectData = treePanel.getProjectData(); @@ -75,11 +82,13 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { @Override public void domainFileAdded(DomainFile file) { + boolean isFolderLink = file.isLink() && file.getLinkInfo().isFolderLink(); String fileName = file.getName(); DomainFolder parentFolder = file.getParent(); - updateLinkedContent(parentFolder, p -> addFileNode(p, fileName, isFolderLink), + updateLinkedContent(parentFolder.getPathname(), p -> addFileNode(p, fileName, isFolderLink), ltn -> ltn.refreshLinks(fileName)); + DomainFolderNode folderNode = findDomainFolderNode(parentFolder, true); if (folderNode != null && folderNode.isLoaded()) { addFileNode(folderNode, fileName, isFolderLink); @@ -88,7 +97,9 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { @Override public void domainFileRemoved(DomainFolder parent, String name, String fileID) { - updateLinkedContent(parent, null, ltn -> ltn.refreshLinks(name)); + + updateLinkedContent(parent.getPathname(), null, ltn -> ltn.refreshLinks(name)); + DomainFolderNode folderNode = findDomainFolderNode(parent, true); if (folderNode != null) { updateChildren(folderNode); @@ -97,14 +108,16 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { @Override public void domainFileRenamed(DomainFile file, String oldName) { + boolean isFolderLink = file.isLink() && file.getLinkInfo().isFolderLink(); - updateLinkedContent(file.getParent(), p -> { + updateLinkedContent(file.getParent().getPathname(), p -> { updateChildren(p); addFileNode(p, file.getName(), isFolderLink); }, ltn -> { ltn.refreshLinks(oldName); ltn.refreshLinks(file.getName()); }); + DomainFolder parent = file.getParent(); skipLinkUpdate = true; try { @@ -124,10 +137,22 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { @Override public void domainFileStatusChanged(DomainFile file, boolean fileIDset) { + + LinkFileInfo linkInfo = file.getLinkInfo(); + boolean isFolderLink = linkInfo != null && linkInfo.isFolderLink(); + DomainFolder parentFolder = file.getParent(); - updateLinkedContent(parentFolder, fn -> { - /* No folder update required */ + updateLinkedContent(parentFolder.getPathname(), fn -> { + // Refresh any linked folder content containing file + if (fn.isLoaded()) { + NodeType type = isFolderLink ? NodeType.FOLDER_LINK : NodeType.FILE; + DomainFileNode fileNode = (DomainFileNode) fn.getChild(file.getName(), type); + if (fileNode != null) { + fileNode.refresh(); + } + } }, ltn -> ltn.refreshLinks(file.getName())); + DomainFileNode fileNode = findDomainFileNode(file, true); if (fileNode != null) { fileNode.refresh(); @@ -141,10 +166,12 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { @Override public void domainFolderAdded(DomainFolder folder) { + String folderName = folder.getName(); DomainFolder parentFolder = folder.getParent(); - updateLinkedContent(parentFolder, p -> addFolderNode(p, folderName), + updateLinkedContent(parentFolder.getPathname(), p -> addFolderNode(p, folderName), ltn -> ltn.refreshLinks(folderName)); + DomainFolderNode folderNode = findDomainFolderNode(parentFolder, true); if (folderNode != null && folderNode.isLoaded()) { addFolderNode(folderNode, folderName); @@ -153,7 +180,9 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { @Override public void domainFolderRemoved(DomainFolder parent, String name) { - updateLinkedContent(parent, null, ltn -> ltn.refreshLinks(name)); + + updateLinkedContent(parent.getPathname(), null, ltn -> ltn.refreshLinks(name)); + DomainFolderNode folderNode = findDomainFolderNode(parent, true); if (folderNode != null) { updateChildren(folderNode); @@ -162,17 +191,12 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { @Override public void domainFolderRenamed(DomainFolder folder, String oldName) { - updateLinkedContent(folder.getParent(), p -> { - updateChildren(p); - addFolderNode(p, folder.getName()); - }, ltn -> { - ltn.refreshLinks(oldName); - ltn.refreshLinks(folder.getName()); - }); - DomainFolder parent = folder.getParent(); + + domainFolderMoved(folder.getParent().getPathname(), oldName, folder); + skipLinkUpdate = true; try { - domainFolderRemoved(parent, oldName); + domainFolderRemoved(folder.getParent(), oldName); domainFolderAdded(folder); } finally { @@ -182,8 +206,17 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { @Override public void domainFolderMoved(DomainFolder folder, DomainFolder oldParent) { - domainFolderRemoved(oldParent, folder.getName()); - domainFolderAdded(folder); + + domainFolderMoved(oldParent.getPathname(), folder.getName(), folder); + + skipLinkUpdate = true; + try { + domainFolderRemoved(oldParent, folder.getName()); + domainFolderAdded(folder); + } + finally { + skipLinkUpdate = false; + } } @Override @@ -194,6 +227,36 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { } } + /** + * Following a folder move or rename where only a single notification is provided this + * method should be used to propogate link related updates which may refer to the affected + * folder or its children. This method is invoked recursively for all child folders. + * @param oldParentPath folder's old parent path + * @param oldName folder's previous name + * @param folder folder instance following rename + */ + private void domainFolderMoved(String oldParentPath, String oldName, DomainFolder folder) { + + String oldFolderPathname = LocalFileSystem.getPath(oldParentPath, oldName); + + // Recurse over all child folders. + for (DomainFolder childFolder : folder.getFolders()) { + domainFolderMoved(oldFolderPathname, childFolder.getName(), childFolder); + } + + // Refresh links to old placement + updateLinkedContent(oldParentPath, null, ltn -> { + ltn.refreshLinks(oldName); + }); + + // Refresh links to new placement + String newName = folder.getName(); + updateLinkedContent(folder.getParent().getPathname(), p -> addFolderNode(p, newName), + ltn -> { + ltn.refreshLinks(newName); + }); + } + // // Helper methods // @@ -210,9 +273,13 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { } private void addFileNode(DataTreeNode node, String fileName, boolean isFolderLink) { + + Msg.debug(this, "addFileNode: " + node.getPathname() + " " + fileName + " " + + Boolean.toString(isFolderLink)); if (node.isLeaf() || !node.isLoaded()) { return; } + // Check for existance of file by that name DomainFileNode fileNode = (DomainFileNode) node.getChild(fileName, isFolderLink ? NodeType.FOLDER_LINK : NodeType.FILE); @@ -234,6 +301,7 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { if (node.isLeaf() || !node.isLoaded()) { return; } + // Check for existance of folder by that name if (node.getChild(folderName, NodeType.FOLDER) != null) { return; @@ -262,6 +330,20 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { return findDomainFolderNode(folderPath, lazy); } +// private List getPathAsList(String pathname) { +// ArrayList folderPath = new ArrayList(); +// String[] pathSplit = pathname.split(FileSystem.SEPARATOR); +// for (int i = 1; i < pathSplit.length; i++) { +// folderPath.add(pathSplit[i]); +// } +// return folderPath; +// } +// +// private DomainFolderNode findDomainFolderNode(String pathname, boolean lazy) { +// List folderPath = getPathAsList(pathname); +// return findDomainFolderNode(folderPath, lazy); +// } + private DomainFolderNode findDomainFolderNode(List folderPath, boolean lazy) { DomainFolderNode folderNode = root; for (String name : folderPath) { @@ -284,6 +366,7 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { if (lazy && !folderNode.isLoaded()) { return null; // not visited } + boolean isFolderLink = domainFile.isLink() && domainFile.getLinkInfo().isFolderLink(); return (DomainFileNode) folderNode.getChild(domainFile.getName(), isFolderLink ? NodeType.FOLDER_LINK : NodeType.FILE); @@ -334,40 +417,51 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { @Override public void treeStructureChanged(TreeModelEvent e) { - // This is used when an existing node is loaded to register all of its link-file children - // since the occurance of treeNodesChanged cannot be relied upon for notification of - // these existing children. + // NOTE: We have seen getTreePath return null in the test environment + // immediately before ChangeManager disposal TreePath treePath = e.getTreePath(); - if (treePath == null) { - return; + + Object[] changedChildren = e.getChildren(); + if (changedChildren != null) { + for (Object child : changedChildren) { + treeNodeChanged(child, true); + } } - Object treeNode = treePath.getLastPathComponent(); + else if (treePath != null) { + treeNodeChanged(treePath.getLastPathComponent(), true); + } + } + + private void treeNodeChanged(Object treeNode, boolean processLoadedChildren) { + if (!(treeNode instanceof DataTreeNode dataTreeNode)) { return; } - if (!dataTreeNode.isLoaded()) { - return; - } - // Register all visible link-file nodes - for (GTreeNode child : dataTreeNode.getChildren()) { - if (child instanceof DomainFileNode fileNode) { - if (fileNode.getDomainFile().isLink()) { - addLinkFile(fileNode); - } - } + + if (treeNode instanceof DomainFileNode fileNode) { + addLinkFile(fileNode); } + + // TODO: Not sure we need the following code +// if (processLoadedChildren && dataTreeNode.isLoaded()) { +// for (GTreeNode node : dataTreeNode.getChildren()) { +// treeNodeChanged(node, true); +// } +// } } @Override public void treeNodesChanged(TreeModelEvent e) { - // This is used to register link-file nodes which may be added to the tree as a result - // of changes to the associated project data. - - Object treeNode = e.getTreePath().getLastPathComponent(); - if (treeNode instanceof DomainFileNode fileNode) { - addLinkFile(fileNode); + Object[] changedChildren = e.getChildren(); + if (changedChildren != null) { + for (Object child : changedChildren) { + treeNodeChanged(child, false); + } + } + else { + treeNodeChanged(e.getTreePath().getLastPathComponent(), false); } } @@ -385,6 +479,19 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { // Link tracking tree update support // + private void addLoadedChildren(DataTreeNode node) { + + if (!node.isLoaded()) { + return; + } + + for (GTreeNode child : node.getChildren()) { + if (child instanceof DomainFileNode fileNode) { + addLinkFile(fileNode); + } + } + } + /** * Update link tree if the specified {@code domainFileNode} corresponds to an link-file * which has an internal link-path which links to either a file or folder within the same @@ -403,6 +510,7 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { } try { + String linkPath = LinkHandler.getAbsoluteLinkPath(file); if (linkPath == null) { return; @@ -420,6 +528,7 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { if (isFolderLink) { folderLinkNode.addLinkedFolder(domainFileNode); + addLoadedChildren(domainFileNode); } else { folderLinkNode.addLinkedFile(pathElements[lastFolderIndex + 1], domainFileNode); @@ -439,39 +548,63 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { * once if a {@code LinkedTreeNode} is found which corresponds to the specified * {@code parentFolder}. This allows targeted refresh of link-files. * - * @param parentFolder a parent folder which relates to a change + * @param parentFolderPath the parent folder path which relates to a change * @param folderNodeConsumer optional consumer which will be invoked for each loaded parent * tree node which is a linked-reflection of the specified {@code parentFolder}. If null is * specified for this consumer a general update will be performed to remove any missing nodes. * @param linkNodeConsumer optional consumer which will be invoked once if a {@code LinkedTreeNode} * is found which corresponds to the specified {@code parentFolder}. */ - void updateLinkedContent(DomainFolder parentFolder, Consumer folderNodeConsumer, - Consumer linkNodeConsumer) { + private void updateLinkedContent(String parentFolderPath, + Consumer folderNodeConsumer, Consumer linkNodeConsumer) { + + if (!Swing.isSwingThread()) { + throw new RuntimeException( + "Listener and all node updates must operate in Swing thread"); + } + if (skipLinkUpdate) { return; } - String pathname = parentFolder.getPathname(); - String[] pathElements = pathname.split("/"); - LinkedTreeNode folderLinkNode = linkTreeRoot; - folderLinkNode.updateLinkedContent(pathElements, 1, folderNodeConsumer); - for (int i = 1; i < pathElements.length; i++) { - folderLinkNode = folderLinkNode.folderMap.get(pathElements[i]); - if (folderLinkNode == null) { - return; // requested folder not contained within link-tree - } - folderLinkNode.updateLinkedContent(pathElements, i + 1, folderNodeConsumer); + + // NOTE: This method must track those paths which have been refreshed to avoid the + // possibility of infinite recursion when circular links exist. + boolean clearRefreshedTrackingSet = false; + if (refreshedTrackingSet == null) { + refreshedTrackingSet = new HashSet<>(); + clearRefreshedTrackingSet = true; } - // Requested folder was found in link-tree - invoke consumer to perform - // selective refresh - if (linkNodeConsumer != null) { - linkNodeConsumer.accept(folderLinkNode); + try { + String[] pathElements = parentFolderPath.split("/"); + LinkedTreeNode folderLinkNode = linkTreeRoot; + folderLinkNode.updateLinkedContent(pathElements, 1, folderNodeConsumer); + for (int i = 1; i < pathElements.length; i++) { + folderLinkNode = folderLinkNode.folderMap.get(pathElements[i]); + if (folderLinkNode == null) { + return; // requested folder not contained within link-tree + } + folderLinkNode.updateLinkedContent(pathElements, i + 1, folderNodeConsumer); + } + + // Requested folder was found in link-tree - invoke consumer to perform + // selective refresh + if (linkNodeConsumer != null) { + linkNodeConsumer.accept(folderLinkNode); + } + } + finally { + if (clearRefreshedTrackingSet) { + refreshedTrackingSet = null; + } } } private class LinkedTreeNode { + // NOTE: The use of HashSet to track LinkedTreeNodes relies on identity hashcode and + // same instance for equality. + private final LinkedTreeNode parent; private final String name; @@ -491,7 +624,14 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { boolean updateThisNode = subFolderPathIndex >= pathElements.length; - for (DomainFileNode folderLink : folderLinks) { + Iterator folderLinkIter = folderLinks.iterator(); + while (folderLinkIter.hasNext()) { + + DomainFileNode folderLink = folderLinkIter.next(); + if (folderLink.getParent() == null) { + // Remove disposed link node + folderLinkIter.remove(); + } if (!folderLink.isLoaded()) { continue; @@ -529,6 +669,25 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { } private void refreshLinks(String childName) { + + String childPathName = LocalFileSystem.getPath(getPathname(), childName); + if (!refreshedTrackingSet.add(childPathName)) { + return; + } + + // If links are defined be sure to visit DomainFolder so that we pickup on change + // events even if not visible within tree. +// TODO: Should no longer be needed after changes were made to force domain folder events +// which would affect discovered link-files +// if (!folderMap.isEmpty()) { +// String path = LocalFileSystem.getPath(getPathname(), childName); +// DomainFolder folder = +// projectData.getFolder(path, DomainFolderFilter.ALL_INTERNAL_FOLDERS_FILTER); +// if (folder != null) { +// folder.getFolders(); // forced visit to folder +// } +// } + // We are forced to refresh file-links and folder-links since a folder-link may be // referencing another folder-link file and not the final referenced folder. if (refreshFileLinks(childName) || refreshFolderLinks(childName)) { @@ -537,10 +696,28 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { } private boolean refreshFolderLinks(String folderName) { + LinkedTreeNode linkedTreeNode = folderMap.get(folderName); if (linkedTreeNode != null) { + refresh(linkedTreeNode.folderLinks); - return linkedTreeNode.folderLinks.isEmpty(); + boolean removed = linkedTreeNode.folderLinks.isEmpty(); + + // Refresh all file links refering to files within this folder + Collection> linkedFileSets = + linkedTreeNode.linkedFilesMap.values(); + if (!linkedFileSets.isEmpty()) { + Iterator> iterator = linkedFileSets.iterator(); + while (iterator.hasNext()) { + Set linkFileSet = iterator.next(); + refresh(linkFileSet); + if (linkFileSet.isEmpty()) { + iterator.remove(); + removed = true; + } + } + } + return removed; } return false; } @@ -557,12 +734,22 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { return false; } + /** + * Add or get existing named child folder node for this folder node + * @param folderName child folder node + * @return new or existing named child folder node + */ private LinkedTreeNode addFolder(String folderName) { return folderMap.computeIfAbsent(folderName, n -> new LinkedTreeNode(this, n)); } - private void addLinkedFolder(DomainFileNode folderLink) { - folderLinks.add(folderLink); + /** + * Add a folder-link which references this folder node + * @param folderLink link which references this folder node + * @return true if the set did not already contain the specified folderLink + */ + private boolean addLinkedFolder(DomainFileNode folderLink) { + return folderLinks.add(folderLink); } private void addLinkedFile(String fileName, DomainFileNode fileLink) { @@ -579,24 +766,28 @@ class ChangeManager implements DomainFolderChangeListener, TreeModelListener { } } - private static void refresh(Set linkFiles) { - List purgeList = null; - for (DomainFileNode fileLink : linkFiles) { - DomainFile file = fileLink.getDomainFile(); - // Perform lazy purge of missing link files - if (!file.isLink()) { - if (purgeList == null) { - purgeList = new ArrayList<>(); - } - purgeList.add(fileLink); + private void refresh(Set linkFiles) { + Iterator linkFileIter = linkFiles.iterator(); + while (linkFileIter.hasNext()) { + DomainFileNode fileLink = linkFileIter.next(); + if (fileLink.getParent() == null || !fileLink.getDomainFile().isLink()) { + linkFileIter.remove(); } else { fileLink.refresh(); + + GTreeNode linkParent = fileLink.getParent(); + if (linkParent instanceof DomainFolderNode linkParentNode) { + + // TODO: What about LinkedDomainFolders? + ChangeManager.this.updateLinkedContent(linkParentNode.getPathname(), fn -> { + /* do nothing */ }, ltn -> { + ltn.refreshLinks(fileLink.getName()); + }); + } + } } - if (purgeList != null) { - linkFiles.removeAll(purgeList); - } } private String getPathname() { diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/DataTree.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/DataTree.java index c4cf844fab..809447c23e 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/DataTree.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/DataTree.java @@ -142,19 +142,7 @@ public class DataTree extends GTree { } else if (node instanceof DomainFileNode fileNode) { if (fileNode.isFolderLink()) { - // Handle case where file node corresponds to a folder-link. - // Folder-Link status needs to be checked to ensure it corresponds to a folder - // internal to the same project. - LinkFileInfo linkInfo = fileNode.getDomainFile().getLinkInfo(); - if (linkInfo == null) { - return null; // unexpected - } - LinkStatus linkStatus = linkInfo.getLinkStatus(null); - if (linkStatus != LinkStatus.INTERNAL) { - return null; - } - // Get linked folder - status check ensures null will not be returned - folder = linkInfo.getLinkedFolder(); + folder = getLinkedFolder(fileNode); } else { // Handle normal file cases where we return node's parent folder @@ -162,8 +150,12 @@ public class DataTree extends GTree { if (parent instanceof DomainFolderNode folderNode) { folder = folderNode.getDomainFolder(); } + else if (parent instanceof DomainFileNode parentFileNode) { + folder = getLinkedFolder(parentFileNode); + } } } + if (folder instanceof LinkedDomainFolder linkedFolder) { // Resolve linked internal folder to its real folder try { @@ -175,4 +167,20 @@ public class DataTree extends GTree { } return folder; } + + private static DomainFolder getLinkedFolder(DomainFileNode fileNode) { + // Handle case where file node corresponds to a folder-link. + // Folder-Link status needs to be checked to ensure it corresponds to a folder + // internal to the same project. + LinkFileInfo linkInfo = fileNode.getDomainFile().getLinkInfo(); + if (linkInfo == null) { + return null; // unexpected + } + LinkStatus linkStatus = linkInfo.getLinkStatus(null); + if (linkStatus != LinkStatus.INTERNAL) { + return null; + } + // Get linked folder - status check ensures null will not be returned + return linkInfo.getLinkedFolder(); + } } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/DataTreeDragNDropHandler.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/DataTreeDragNDropHandler.java index 6e3798f48c..455a3bb32c 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/DataTreeDragNDropHandler.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/DataTreeDragNDropHandler.java @@ -174,7 +174,9 @@ public class DataTreeDragNDropHandler implements GTreeDragNDropHandler { private List getDomainParentNodes(List nodeList) { List parentList = new ArrayList<>(); for (GTreeNode node : nodeList) { - if (!node.isLeaf()) { + if (node instanceof DomainFolderNode) { + // We want to ensure we treat link-file node as not being a parent + // for this operation. parentList.add(node); } } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/DataTreeNode.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/DataTreeNode.java index a1537eae5f..5413f340b4 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/DataTreeNode.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/DataTreeNode.java @@ -94,6 +94,11 @@ public abstract class DataTreeNode extends GTreeSlowLoadingNode implements Cutta */ public abstract ProjectData getProjectData(); + /** + * @returns domain folder/file pathname within project + */ + public abstract String getPathname(); + @Override public abstract int compareTo(GTreeNode node); diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/DomainFileNode.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/DomainFileNode.java index 77fa76968f..0b1f285cd4 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/DomainFileNode.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/DomainFileNode.java @@ -46,7 +46,12 @@ public class DomainFileNode extends DataTreeNode { private static final Icon UNKNOWN_FILE_ICON = new GIcon("icon.datatree.node.domain.file"); private static final String RIGHT_ARROW = "\u2192"; + // NOTE: We must ensure anything used by sort comparator remains fixed private final DomainFile domainFile; + private final boolean isFolderLink; + + private LinkFileInfo linkInfo; + private boolean isLeaf; private volatile String displayName; // name displayed in the tree private volatile Icon icon = UNKNOWN_FILE_ICON; @@ -54,15 +59,14 @@ public class DomainFileNode extends DataTreeNode { private volatile String toolTipText; private AtomicInteger refreshCount = new AtomicInteger(); - private boolean isLeaf = true; - private LinkFileInfo linkInfo; private DomainFileFilter filter; // relavent when expand folder-link which is a file private static final SimpleDateFormat formatter = new SimpleDateFormat("yyyy MMM dd hh:mm aaa"); DomainFileNode(DomainFile domainFile, DomainFileFilter filter) { this.domainFile = domainFile; - this.linkInfo = domainFile.getLinkInfo(); + linkInfo = domainFile.getLinkInfo(); + isFolderLink = linkInfo != null && linkInfo.isFolderLink(); this.filter = filter != null ? filter : DomainFileFilter.ALL_FILES_FILTER; displayName = domainFile.getName(); refresh(); @@ -84,6 +88,11 @@ public class DomainFileNode extends DataTreeNode { return domainFile; } + @Override + public String getPathname() { + return domainFile.getPathname(); + } + @Override public boolean isLeaf() { return isLeaf; @@ -103,15 +112,11 @@ public class DomainFileNode extends DataTreeNode { * @return true if file is a folder-link */ public boolean isFolderLink() { - if (linkInfo != null) { - return linkInfo.isFolderLink(); - } - return false; + return isFolderLink; } /** - * Get linked folder which corresponds to this folder-link - * (see {@link #isFolderLink()}). + * Get linked folder which corresponds to this folder-link (see {@link #isFolderLink()}). * @return linked folder or null if this is not a folder-link */ LinkedDomainFolder getLinkedFolder() { @@ -214,33 +219,42 @@ public class DomainFileNode extends DataTreeNode { private void doRefresh() { isLeaf = true; - linkInfo = null; + LinkFileInfo updatedLinkInfo = domainFile.getLinkInfo(); boolean brokenLink = false; List linkErrors = null; - if (domainFile.isLink()) { - linkInfo = domainFile.getLinkInfo(); - List errors = new ArrayList<>(); - LinkStatus linkStatus = - LinkHandler.getLinkFileStatus(domainFile, msg -> errors.add(msg)); - brokenLink = (linkStatus == LinkStatus.BROKEN); - if (brokenLink) { - linkErrors = errors; - } - else if (isFolderLink()) { - if (linkStatus == LinkStatus.INTERNAL) { - isLeaf = false; + + if (isFolderLink != (updatedLinkInfo != null && updatedLinkInfo.isFolderLink())) { + // Linked-folder node state changed. Since this alters sort order we can't handle this. + // Such a DomainFile state change must be handled by the ChangeManager + brokenLink = true; + linkErrors = List.of("Unsupported folder-link transition"); + } + else { + linkInfo = updatedLinkInfo; + if (linkInfo != null) { + List errors = new ArrayList<>(); + LinkStatus linkStatus = + LinkHandler.getLinkFileStatus(domainFile, msg -> errors.add(msg)); + brokenLink = (linkStatus == LinkStatus.BROKEN); + if (brokenLink) { + linkErrors = errors; } - else if (linkStatus == LinkStatus.EXTERNAL && - filter.followExternallyLinkedFolders()) { - isLeaf = false; + else if (isFolderLink) { + if (linkStatus == LinkStatus.INTERNAL) { + isLeaf = false; + } + else if (linkStatus == LinkStatus.EXTERNAL && + filter.followExternallyLinkedFolders()) { + isLeaf = false; + } } } } - if (isLeaf) { - unloadChildren(); - } + // We must always unload any children since a leaf has no children and a folder-link + // may be transitioning to a state where its children may need to be re-loaded. + unloadChildren(); displayName = getFormattedDisplayName(); @@ -289,7 +303,25 @@ public class DomainFileNode extends DataTreeNode { private String getFormattedLinkPath() { - String linkPath = linkInfo != null ? linkInfo.getLinkPath() : null; + String linkPath = null; + + // If link-file is a LinkedDomainFile we must always show an absolute link-path since + // relative paths are relative to the real file's location and it would be rather confusing + // to show as relative + if (domainFile instanceof LinkedDomainFile) { + try { + // will return GhidraURL or absolute internal path + linkPath = LinkHandler.getAbsoluteLinkPath(domainFile); + } + catch (IOException e) { + // attempt to use stored path, although it may fail as well + linkPath = linkInfo.getLinkPath(); + } + } + else if (linkInfo != null) { + linkPath = linkInfo.getLinkPath(); + } + if (GhidraURL.isGhidraURL(linkPath)) { try { URL url = new URL(linkPath); diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/DomainFolderNode.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/DomainFolderNode.java index c7f48fa5c5..05a662728d 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/DomainFolderNode.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/DomainFolderNode.java @@ -20,6 +20,7 @@ import java.util.List; import javax.swing.Icon; +import docking.widgets.tree.GTree; import docking.widgets.tree.GTreeNode; import ghidra.framework.model.*; import ghidra.util.*; @@ -48,6 +49,7 @@ public class DomainFolderNode extends DataTreeNode { private boolean isEditable; DomainFolderNode(DomainFolder domainFolder, DomainFileFilter filter) { + this.domainFolder = domainFolder; this.filter = filter; @@ -73,6 +75,11 @@ public class DomainFolderNode extends DataTreeNode { return domainFolder; } + @Override + public String getPathname() { + return domainFolder.getPathname(); + } + /** * Returns true if this node has no children. */ diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/LocalTreeNodeHandler.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/LocalTreeNodeHandler.java index 37458a911c..510b479057 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/LocalTreeNodeHandler.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/LocalTreeNodeHandler.java @@ -112,23 +112,35 @@ public final class LocalTreeNodeHandler } private boolean isValidDrag(DomainFolder destFolder, GTreeNode draggedNode) { - // NOTE: We may have issues since checks are not based on canonical paths - if (draggedNode instanceof DomainFolderNode folderNode) { - // This also checks cases where src/dest projects are using the same repository. - // Unfortunately, it will also prevent cases where shared-project folder - // does not contain versioned content and could actually be allowed. - DomainFolder folder = folderNode.getDomainFolder(); - return !folder.isSameOrAncestor(destFolder); - } - if (draggedNode instanceof DomainFileNode fileNode) { - DomainFolder folder = fileNode.getDomainFile().getParent(); - DomainFile file = fileNode.getDomainFile(); - if (file.isVersioned()) { + try { + // NOTE: destFolder should be real folder and not linked-folder + // NOTE: We may have issues since checks are not based on canonical paths + if (draggedNode instanceof DomainFolderNode folderNode) { // This also checks cases where src/dest projects are using the same repository. - return !folder.isSame(destFolder); + // Unfortunately, it will also prevent cases where shared-project folder + // does not contain versioned content and could actually be allowed. + DomainFolder folder = folderNode.getDomainFolder(); + if (folder instanceof LinkedDomainFolder linkedFolder) { + folder = linkedFolder.getRealFolder(); + } + return !folder.isSameOrAncestor(destFolder); } - DomainFile destFile = destFolder.getFile(file.getName()); - return destFile == null || !destFile.equals(file); + if (draggedNode instanceof DomainFileNode fileNode) { + DomainFile file = fileNode.getDomainFile(); + if (file instanceof LinkedDomainFile linkedFile) { + file = linkedFile.getRealFile(); + } + DomainFolder folder = file.getParent(); + if (file.isVersioned()) { + // This also checks cases where src/dest projects are using the same repository. + return !folder.isSame(destFolder); + } + DomainFile destFile = destFolder.getFile(file.getName()); + return destFile == null || !destFile.equals(file); + } + } + catch (IOException e) { + // ignore } return false; } @@ -165,6 +177,9 @@ public final class LocalTreeNodeHandler } try { + if (file instanceof LinkedDomainFile linkedFile) { + file = linkedFile.getRealFile(); + } file.moveTo(destFolder); } catch (IOException e) { @@ -186,6 +201,9 @@ public final class LocalTreeNodeHandler } try { + if (sourceFolder instanceof LinkedDomainFolder linkedFolder) { + sourceFolder = linkedFolder.getRealFolder(); + } sourceFolder.moveTo(destFolder); } catch (DuplicateFileException dfe) { diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/DeleteProjectFilesTask.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/DeleteProjectFilesTask.java index 61ba49e411..a82cc4e01e 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/DeleteProjectFilesTask.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/DeleteProjectFilesTask.java @@ -17,8 +17,7 @@ package ghidra.framework.main.projectdata.actions; import java.awt.Component; import java.io.IOException; -import java.util.Objects; -import java.util.Set; +import java.util.*; import docking.widgets.OptionDialog; import docking.widgets.OptionDialogBuilder; @@ -65,10 +64,21 @@ public class DeleteProjectFilesTask extends Task { initializeMonitor(monitor); - deleteFiles(selectedFiles, monitor); + Set resolvedFiles = resolveLinkedFiles(selectedFiles); - deleteFolders(selectedFolders, monitor); + Set resolvedFolders = resolveLinkedFolders(selectedFolders); + try { + deleteFiles(resolvedFiles, monitor); + deleteFolders(resolvedFolders, monitor); + } + catch (CancelledException e) { + // ignore + } + + } + + public void showReport() { statistics.showReport(parent); } @@ -77,60 +87,105 @@ public class DeleteProjectFilesTask extends Task { monitor.initialize(statistics.getFileCount()); } - private void deleteFiles(Set files, TaskMonitor monitor) { - try { - for (DomainFile file : files) { - monitor.checkCancelled(); - deleteFile(file); - monitor.incrementProgress(1); + /** + * Domain file comparator for use in establishing order of file removal. + * All real files (i.e., non-link-files) must be removed before link-files + * afterwhich depth-first applies. + */ + private static final Comparator FILE_PATH_COMPARATOR = + new Comparator() { + + @Override + public int compare(DomainFile o1, DomainFile o2) { + + // Ensure that non-link-files occur first in sorted list + boolean isLink1 = o1.isLink(); + boolean isLink2 = o2.isLink(); + if (isLink1) { + if (!isLink2) { + return 1; + } + } + else if (isLink2) { + return -1; + } + + // Compare paths to ensure deeper paths occur first in sorted list + String path1 = o1.getPathname(); + String path2 = o2.getPathname(); + return path2.compareTo(path1); } + }; + + private Set resolveLinkedFiles(Set files) { + Set resolvedFiles = new HashSet<>(); + for (DomainFile file : files) { + // If file is contained within a linked-folder (LinkedDomainFile) we need to + // use the actual linked file. Since we should be dealing with internally + // linked content IOExceptions are unexpected. + if (file instanceof LinkedDomainFile linkedFile) { + try { + file = linkedFile.getRealFile(); + } + catch (IOException e) { + continue; // Skip file if unable to resolve + } + } + resolvedFiles.add(file); } - catch (CancelledException e) { - // just return so that statistics for what completed can be displayed + return resolvedFiles; + } + + private Set resolveLinkedFolders(Set folders) { + Set resolvedFolders = new HashSet<>(); + for (DomainFolder folder : folders) { + // If folder is a linked-folder (LinkedDomainFolder) we need to + // use the actual linked folder. Since we should be dealing with internally + // linked content IOExceptions are unexpected. + if (folder instanceof LinkedDomainFolder linkedFolder) { + try { + folder = linkedFolder.getRealFolder(); + } + catch (IOException e) { + continue; // skip file if unable to resolve + } + } + resolvedFolders.add(folder); + } + return resolvedFolders; + } + + private void deleteFiles(Set files, TaskMonitor monitor) throws CancelledException { + + ArrayList sortedFiles = new ArrayList<>(files); + Collections.sort(sortedFiles, FILE_PATH_COMPARATOR); + + for (DomainFile file : sortedFiles) { + monitor.checkCancelled(); + deleteFile(file, monitor); } } - private void deleteFolders(Set folders, TaskMonitor monitor) { + private void deleteFolders(Set folders, TaskMonitor monitor) + throws CancelledException { - try { - for (DomainFolder folder : folders) { - deleteFolder(folder, monitor); - } - } - catch (CancelledException e) { - // just return so that statistics for what completed can be displayed + for (DomainFolder folder : folders) { + monitor.checkCancelled(); + deleteFolder(folder, monitor); } } private void deleteFolder(DomainFolder folder, TaskMonitor monitor) throws CancelledException { - while (folder instanceof LinkedDomainFolder linkedFolder) { - if (linkedFolder.isLinked()) { - throw new IllegalArgumentException( - "Linked-folder's originating file-link should have been removed instead: " + - linkedFolder.getPathname()); - } - try { - folder = linkedFolder.getRealFolder(); - } - catch (IOException e) { - Msg.error(this, "Error following linked-folder: " + e.getMessage() + "\n" + - folder.getPathname()); - return; - } - } - for (DomainFolder subFolder : folder.getFolders()) { monitor.checkCancelled(); - if (!selectedFolders.contains(subFolder)) { - deleteFolder(subFolder, monitor); - } + deleteFolder(subFolder, monitor); } for (DomainFile file : folder.getFiles()) { monitor.checkCancelled(); if (!selectedFiles.contains(file)) { - deleteFile(file); + deleteFile(file, monitor); monitor.incrementProgress(1); } } @@ -149,42 +204,47 @@ public class DeleteProjectFilesTask extends Task { } } - private void deleteFile(DomainFile file) throws CancelledException { - if (file.isOpen()) { - statistics.incrementFileInUse(); - showFileInUseDialog(file); + private void deleteFile(DomainFile file, TaskMonitor monitor) throws CancelledException { + + if (!file.exists()) { return; } - if (file.isVersioned() && file.isCheckedOut()) { - showCheckedOutVersionedDialog(file); - statistics.incrementCheckedOutVersioned(); - return; - } - - if (file.isReadOnly()) { - int result = showConfirmReadOnlyDialog(file); - if (result == OptionDialog.CANCEL_OPTION) { - throw new CancelledException(); - } - if (result != OptionDialog.YES_OPTION) { - statistics.incrementReadOnly(); - return; - } - } - - if (file.isVersioned()) { - int result = showConfirmDeleteVersionedDialog(file); - if (result == OptionDialog.CANCEL_OPTION) { - throw new CancelledException(); - } - if (result != OptionDialog.YES_OPTION) { - statistics.incrementVersioned(); - return; - } - } - try { + if (file.isOpen()) { + statistics.incrementFileInUse(); + showFileInUseDialog(file); + return; + } + + if (file.isVersioned() && file.isCheckedOut()) { + showCheckedOutVersionedDialog(file); + statistics.incrementCheckedOutVersioned(); + return; + } + + if (file.isReadOnly()) { + int result = showConfirmReadOnlyDialog(file); + if (result == OptionDialog.CANCEL_OPTION) { + throw new CancelledException(); + } + if (result != OptionDialog.YES_OPTION) { + statistics.incrementReadOnly(); + return; + } + } + + if (file.isVersioned()) { + int result = showConfirmDeleteVersionedDialog(file); + if (result == OptionDialog.CANCEL_OPTION) { + throw new CancelledException(); + } + if (result != OptionDialog.YES_OPTION) { + statistics.incrementVersioned(); + return; + } + } + file.delete(); statistics.incrementDeleted(); } @@ -198,6 +258,9 @@ public class DeleteProjectFilesTask extends Task { throw new CancelledException(); } } + finally { + monitor.increment(); + } } private int showConfirmDeleteVersionedDialog(DomainFile file) { diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/FileCountStatistics.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/FileCountStatistics.java index b5a3c8d87b..049ae45cd6 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/FileCountStatistics.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/FileCountStatistics.java @@ -4,9 +4,9 @@ * 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. @@ -31,6 +31,8 @@ class FileCountStatistics { private int deleted; FileCountStatistics(int fileCount) { + // NOTE: Do the possibility of file duplication through the selection of linked-folder + // content, this count is an estimate only this.fileCount = fileCount; } @@ -72,9 +74,10 @@ class FileCountStatistics { public void showReport(Component parent) { // don't show results if only one file processed. - if (getTotalProcessed() == 1) { + if (fileCount == 1 && getTotalProcessed() == 1) { return; } + // don't show results if all selected files deleted if (deleted == fileCount) { return; @@ -97,20 +100,24 @@ class FileCountStatistics { builder.append("In Use: ").append(fileInUse).append(""); } if (versionedDeclined > 0) { - builder.append(" Versioned: ").append(versionedDeclined).append( - ""); + builder.append(" Versioned: ") + .append(versionedDeclined) + .append(""); } if (checkedOutVersioned > 0) { - builder.append("Checked-out: ").append(checkedOutVersioned).append( - ""); + builder.append("Checked-out: ") + .append(checkedOutVersioned) + .append(""); } if (readOnlySkipped > 0) { - builder.append("Read only: ").append(readOnlySkipped).append( - ""); + builder.append("Read only: ") + .append(readOnlySkipped) + .append(""); } if (generalFailure > 0) { - builder.append("Other: ").append(generalFailure).append( - ""); + builder.append("Other: ") + .append(generalFailure) + .append(""); } builder.append(""); } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/ProjectDataDeleteAction.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/ProjectDataDeleteAction.java index 4aa8698fbd..6f87539e01 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/ProjectDataDeleteAction.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/ProjectDataDeleteAction.java @@ -58,23 +58,28 @@ public class ProjectDataDeleteAction extends FrontendProjectTreeAction { // Confirm the delete *without* using a task so that do not have 2 dialogs showing int fileCount = countTask.getFileCount(); - if (!confirmDelete(fileCount, files, context.getComponent())) { + if (!confirmDelete(fileCount, files, folders, context.getComponent())) { return; } // Task 2 - perform the delete--this could take a while DeleteProjectFilesTask deleteTask = createDeleteTask(context, files, folders, fileCount); TaskLauncher.launch(deleteTask); - } + if (!deleteTask.isCancelled()) { + deleteTask.showReport(); + } + } + DeleteProjectFilesTask createDeleteTask(ProjectDataContext context, Set files, Set folders, int fileCount) { return new DeleteProjectFilesTask(folders, files, fileCount, context.getComponent()); } - private boolean confirmDelete(int fileCount, Set files, Component parent) { + private boolean confirmDelete(int fileCount, Set files, Set folders, + Component parent) { - String message = getMessage(fileCount, files); + String message = getMessage(fileCount, files, folders); OptionDialogBuilder builder = new OptionDialogBuilder("Confirm Delete", message); int choice = builder.addOption("OK") .addCancel() @@ -83,28 +88,25 @@ public class ProjectDataDeleteAction extends FrontendProjectTreeAction { return choice != OptionDialog.CANCEL_OPTION; } - private String getMessage(int fileCount, Set selectedFiles) { + private String getMessage(int fileCount, Set files, Set folders) { if (fileCount == 0) { return "Are you sure you want to delete the selected empty folder(s)?"; } - if (fileCount == 1) { - if (!selectedFiles.isEmpty()) { - DomainFile file = CollectionUtils.any(selectedFiles); - String type = file.isLink() ? "link" : "file"; + if (folders.isEmpty()) { + if (fileCount == 1) { + DomainFile file = CollectionUtils.any(files); + String type = file.getContentType(); return "Are you sure you want to permanently delete " + type + " \"" + HTMLUtilities.escapeHTML(file.getName()) + "\"?"; } - - // only folders are selected, but they contain files return "Are you sure you want to permanently delete the " + - " selected files and folders?"; + " selected files?"; } // multiple files selected - return "Are you sure you want to permanently delete the " + fileCount + - " selected files?"; + return "Are you sure you want to permanently delete the selected folder(s) and file(s)?"; } @Override diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/ProjectDataNewFolderAction.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/ProjectDataNewFolderAction.java index 17e3b4bf7a..d75fe1627e 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/ProjectDataNewFolderAction.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/ProjectDataNewFolderAction.java @@ -25,8 +25,7 @@ import docking.widgets.tree.GTreeNode; import generic.theme.GIcon; import ghidra.framework.main.datatable.ProjectTreeContext; import ghidra.framework.main.datatree.*; -import ghidra.framework.model.DomainFile; -import ghidra.framework.model.DomainFolder; +import ghidra.framework.model.*; import ghidra.util.InvalidNameException; import ghidra.util.exception.AssertException; @@ -48,26 +47,32 @@ public class ProjectDataNewFolderAction @Override protected boolean isEnabledForContext(T context) { - return getFolder(context).isInWritableProject(); + try { + return getFolder(context).isInWritableProject(); + } + catch (IOException e) { + return false; + } } private void createNewFolder(T context) { - - DomainFolder parentFolder = getFolder(context); - DomainFolder newFolder = createNewFolderWithDefaultName(parentFolder); + DomainFolder newFolder = createNewFolderWithDefaultName(context); GTreeNode parent = getParentNode(context); DataTree tree = context.getTree(); tree.setEditable(true); tree.startEditing(parent, newFolder.getName()); } - private DomainFolder createNewFolderWithDefaultName(DomainFolder parentFolder) { - String name = getNewFolderName(parentFolder); + private DomainFolder createNewFolderWithDefaultName(T context) { + String errName = ""; try { + DomainFolder parentFolder = getFolder(context); + String name = getNewFolderName(parentFolder); + errName = ": " + name; return parentFolder.createFolder(name); } catch (InvalidNameException | IOException e) { - throw new AssertException("Unexpected Error creating new folder: " + name, e); + throw new AssertException("Unexpected Error creating new folder" + errName, e); } } @@ -82,18 +87,33 @@ public class ProjectDataNewFolderAction return name; } - private DomainFolder getFolder(T context) { - // the following code relies on the isAddToPopup to ensure that there is exactly one + private DomainFolder getFolder(T context) throws IOException { + // the following code relied upon by the isAddToPopup to ensure that there is exactly one // file or folder selected + DomainFolder folder = null; if (context.getFolderCount() == 1 && context.getFileCount() == 0) { - return context.getSelectedFolders().get(0); + folder = context.getSelectedFolders().get(0); } - if (context.getFileCount() == 1 && context.getFolderCount() == 0) { + else if (context.getFileCount() == 1 && context.getFolderCount() == 0) { DomainFile file = context.getSelectedFiles().get(0); - return file.getParent(); + LinkFileInfo linkInfo = file.getLinkInfo(); + if (linkInfo != null && linkInfo.isFolderLink()) { + folder = linkInfo.getLinkedFolder(); + } + else { + folder = file.getParent(); + } } - DomainFolderNode rootNode = (DomainFolderNode) context.getTree().getModelRoot(); - return rootNode.getDomainFolder(); + if (folder instanceof LinkedDomainFolder linkedFolder) { + // Use real folder associated with linked file/folder selection + folder = linkedFolder.getRealFolder(); + } + if (folder == null) { + // Use root folder if valid selection not found + DomainFolderNode rootNode = (DomainFolderNode) context.getTree().getModelRoot(); + folder = rootNode.getDomainFolder(); + } + return folder; } private GTreeNode getParentNode(T context) { @@ -104,8 +124,10 @@ public class ProjectDataNewFolderAction return context.getTree().getModelRoot(); } - if (node instanceof DomainFileNode) { - return node.getParent(); + if (node instanceof DomainFileNode fileNode) { + if (!fileNode.isFolderLink()) { + return node.getParent(); + } } return node; } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/DomainFolderChangeListener.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/DomainFolderChangeListener.java index 92afd300bc..e87be81326 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/DomainFolderChangeListener.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/DomainFolderChangeListener.java @@ -4,9 +4,9 @@ * 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. @@ -59,6 +59,11 @@ public interface DomainFolderChangeListener { /** * Notify listeners when a domain folder is renamed. + *

+ * NOTE: Only a single event will be sent for the specific folder renamed and not its children. + * If the listener cares about the impact of this event on the folder's children it will need + * to process accordingly. + * * @param folder folder that was renamed * @param oldName old name of folder */ @@ -77,6 +82,11 @@ public interface DomainFolderChangeListener { /** * Notification that the domain folder was moved. + *

+ * NOTE: Only a single event will be sent for the specific folder moved and not its children. + * If the listener cares about the impact of this event on the folder's children it will need + * to process accordingly. + * * @param folder the folder (after move) * @param oldParent original parent folder */ diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/LinkFileInfo.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/LinkFileInfo.java index 22c401096a..3a9326aa9d 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/LinkFileInfo.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/LinkFileInfo.java @@ -59,8 +59,11 @@ public interface LinkFileInfo { * method on an {@link #isExternalLink() external-link} will cause the associated * project or repository to be opened and associated with the active project as a * a viewed-project. The resulting folder instance will return true to the method - * {@link DomainFolder#isLinked()}. This method will recurse all internal folder-links - * which may be chained together. + * {@link DomainFolder#isLinked()}. + * + * NOTE: This method will recurse all internal folder-links which may be chained together + * and relies on link status checks to prevent possible recursion (See + * {@link LinkHandler#getLinkFileStatus(DomainFile, Consumer)}). * * @return a linked domain folder or null if not a valid folder-link. */ @@ -71,7 +74,10 @@ public interface LinkFileInfo { * link-file's project or a Ghidra URL. *

* If you want to ensure that a project path returned is absolute and normalized, then - * {@link #getAbsoluteLinkPath()} may be used. + * {@link #getAbsoluteLinkPath()} may be used. If this corresponds to a link-file that + * implements {@link LinkedDomainFile} the absolute path form must be used to avoid treating + * as relative to the incorrect parent folder. A {@link LinkedDomainFile} can occur when + * the link-file exists within a linked-folder or subfolder. * * @return associated link path */ diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/LinkedDomainFile.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/LinkedDomainFile.java index bfc8df833c..69f8927536 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/LinkedDomainFile.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/LinkedDomainFile.java @@ -35,6 +35,6 @@ public interface LinkedDomainFile extends DomainFile { * @return domain file * @throws IOException if IO error occurs or file not found */ - public DomainFile getLinkedFile() throws IOException; + public DomainFile getRealFile() throws IOException; } diff --git a/Ghidra/Framework/Project/src/test/java/ghidra/framework/data/RelativePathTest.java b/Ghidra/Framework/Project/src/test/java/ghidra/framework/data/RelativePathTest.java new file mode 100644 index 0000000000..c97519a427 --- /dev/null +++ b/Ghidra/Framework/Project/src/test/java/ghidra/framework/data/RelativePathTest.java @@ -0,0 +1,47 @@ +/* ### + * 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.data; + +import static org.junit.Assert.*; + +import org.junit.Test; + +import generic.test.AbstractGenericTest; + +public class RelativePathTest extends AbstractGenericTest { + + @Test + public void testGetRelativePath() { + + // File links + assertEquals("../b", GhidraFolderData.getRelativePath("/a/b/../b", "/a/b", false)); + assertEquals("../b", GhidraFolderData.getRelativePath("/a/b", "/a/b", false)); + assertEquals("c", GhidraFolderData.getRelativePath("/a/b/c", "/a/b", false)); + assertEquals("../c", GhidraFolderData.getRelativePath("/a/b/c", "/a/b/d", false)); + + // Folder links + assertEquals(".", GhidraFolderData.getRelativePath("/a/b/../b", "/a/b", true)); + assertEquals(".", GhidraFolderData.getRelativePath("/a/b", "/a/b", true)); + assertEquals("c", GhidraFolderData.getRelativePath("/a/b/c", "/a/b", true)); + assertEquals("../c", GhidraFolderData.getRelativePath("/a/b/c", "/a/b/d", true)); + assertEquals(".", GhidraFolderData.getRelativePath("/a/b/../b/", "/a/b", true)); // See Note-1 + assertEquals(".", GhidraFolderData.getRelativePath("/a/b/", "/a/b", true)); + assertEquals("c/", GhidraFolderData.getRelativePath("/a/b/c/", "/a/b", true)); + assertEquals("../c/", GhidraFolderData.getRelativePath("/a/b/c/", "/a/b/d", true)); + + } + +} diff --git a/Ghidra/Framework/Project/src/test/java/ghidra/framework/main/projectdata/actions/DeleteProjectFilesTaskTest.java b/Ghidra/Framework/Project/src/test/java/ghidra/framework/main/projectdata/actions/DeleteProjectFilesTaskTest.java index 42b72f195d..5b59223aa5 100644 --- a/Ghidra/Framework/Project/src/test/java/ghidra/framework/main/projectdata/actions/DeleteProjectFilesTaskTest.java +++ b/Ghidra/Framework/Project/src/test/java/ghidra/framework/main/projectdata/actions/DeleteProjectFilesTaskTest.java @@ -4,9 +4,9 @@ * 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. @@ -57,6 +57,8 @@ public class DeleteProjectFilesTaskTest extends AbstractDockingTest { @Before public void setUp() throws Exception { + // NOTE: TestDummyDomainFolder is inadequate to test against linked-folder cases + // which requires real project data support. root = new TestDummyDomainFolder(null, "root"); a = root.createFolder("a"); b = root.createFolder("b"); @@ -488,9 +490,9 @@ public class DeleteProjectFilesTaskTest extends AbstractDockingTest { private void runAction() { - ActionContext context = new ProjectDataContext(/*provider*/null, /*project data*/null, - /*context object*/ null, CollectionUtils.asList(folders), CollectionUtils.asList(files), - null, true); + ActionContext context = + new ProjectDataContext(/*provider*/null, /*project data*/null, /*context object*/ null, + CollectionUtils.asList(folders), CollectionUtils.asList(files), null, true); performAction(deleteAction, context, false); waitForSwing(); } diff --git a/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/datatree/ProjectCopyPasteFromRepositoryTest.java b/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/datatree/ProjectCopyPasteFromRepositoryTest.java index 6cd77609b6..ab22099d50 100644 --- a/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/datatree/ProjectCopyPasteFromRepositoryTest.java +++ b/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/datatree/ProjectCopyPasteFromRepositoryTest.java @@ -526,7 +526,6 @@ public class ProjectCopyPasteFromRepositoryTest extends AbstractGhidraHeadedInte // of folder or another folder-link-file at the referenced location // String urlPath = sharedFolderURL.toExternalForm(); // will end with '/' - urlPath = urlPath.substring(0, urlPath.length() - 1); // strip trailing '/' assertEquals(urlPath, linkInfo.getLinkPath()); @@ -593,7 +592,7 @@ public class ProjectCopyPasteFromRepositoryTest extends AbstractGhidraHeadedInte viewTreeHelper.getDomainFileActionContext(f1LinkFile); URL sharedFolderURL = GhidraURL.makeURL("localhost", ServerTestUtil.GHIDRA_TEST_SERVER_PORT, - "Test", "/f1Link", null); + "Test", "/f1Link/", null); DockingActionIf copyAction = getAction(env.getFrontEndTool(), "Copy"); assertNotNull("Copy action not found", copyAction); diff --git a/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/datatree/ProjectDataTreeTest.java b/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/datatree/ProjectDataTreeTest.java new file mode 100644 index 0000000000..5d3c80c9ed --- /dev/null +++ b/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/datatree/ProjectDataTreeTest.java @@ -0,0 +1,420 @@ +/* ### + * 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.main.datatree; + +import static org.junit.Assert.*; + +import java.util.function.BooleanSupplier; + +import org.junit.*; + +import docking.widgets.tree.GTree; +import docking.widgets.tree.GTreeNode; +import ghidra.framework.data.FolderLinkContentHandler; +import ghidra.framework.model.DomainFile; +import ghidra.framework.model.DomainFolder; +import ghidra.program.database.ProgramLinkContentHandler; +import ghidra.program.model.listing.Program; +import ghidra.test.*; +import ghidra.util.Swing; +import ghidra.util.task.TaskMonitor; + +public class ProjectDataTreeTest extends AbstractGhidraHeadedIntegrationTest { + + private FrontEndTestEnv env; + + private DomainFile programAFile; + + private Program program; + + @Before + public void setUp() throws Exception { + env = new FrontEndTestEnv(); + program = ToyProgramBuilder.buildSimpleProgram("foo", this); + + DomainFolder rootFolder = env.getRootFolder(); + programAFile = rootFolder.getFile("Program_A"); + assertNotNull(programAFile); + } + + @After + public void tearDown() throws Exception { + if (program != null) { + program.release(this); + } + env.dispose(); + } + + @Test + public void testLinkFileUpdate() throws Exception { + + GTree tree = env.getTree(); + GTreeNode modelRoot = tree.getModelRoot(); + DomainFolder rootFolder = env.getRootFolder(); + + DomainFolder aFolder = rootFolder.createFolder("A"); + + // file link created before referenced file + aFolder.createLinkFile(rootFolder.getProjectData(), "/A/x", true, "y", + ProgramLinkContentHandler.INSTANCE); + + rootFolder.createLinkFile(rootFolder.getProjectData(), "/A", false, "B", + FolderLinkContentHandler.INSTANCE); + + env.waitForTree(); + + Swing.runNow(() -> tree.expandPath(modelRoot.getChild("A"))); + env.waitForTree(); + + Swing.runNow(() -> tree.expandPath(modelRoot.getChild("B"))); + env.waitForTree(); + + // Add file 'x' while folder A and linked-folder B are both expanded + aFolder.createFile("x", program, TaskMonitor.DUMMY); + program.release(this); + program = null; + + env.waitForTree(); + + // + // Verify good state after everything created + // + // /A + // x + // y -> x + // /B -> /A (linked-folder) + // x + // y -> x + // + + DomainFolderNode aFolderNode = (DomainFolderNode) modelRoot.getChild("A"); + DomainFileNode xNode = (DomainFileNode) aFolderNode.getChild("x"); + assertNotNull(xNode); + + DomainFileNode yNode = (DomainFileNode) aFolderNode.getChild("y"); + assertNotNull(yNode); + waitForRefresh(yNode); + + String tip = yNode.getToolTip(); + assertFalse(tip.contains("Broken")); + + xNode = (DomainFileNode) aFolderNode.getChild("x"); + assertNotNull(xNode); + + DomainFileNode bFolderLinkNode = (DomainFileNode) modelRoot.getChild("B"); + yNode = (DomainFileNode) bFolderLinkNode.getChild("y"); + assertNotNull(yNode); + waitForRefresh(yNode); + + tip = yNode.getToolTip(); + assertFalse(tip.contains("Broken")); + + // Remove 'x' file and verify broken links are reflected + + xNode = (DomainFileNode) aFolderNode.getChild("x"); + xNode.getDomainFile().delete(); + + env.waitForTree(); + + assertNull(aFolderNode.getChild("x")); + + yNode = (DomainFileNode) aFolderNode.getChild("y"); + assertNotNull(yNode); + waitForRefresh(yNode); + + tip = yNode.getToolTip(); + assertTrue(tip.contains("Broken")); + + xNode = (DomainFileNode) aFolderNode.getChild("x"); + assertNull(xNode); + + yNode = (DomainFileNode) bFolderLinkNode.getChild("y"); + assertNotNull(yNode); + waitForRefresh(yNode); + + tip = yNode.getToolTip(); + assertTrue(tip.contains("Broken")); + + } + + @Test + public void testLinkFileUpdate1() throws Exception { + + GTree tree = env.getTree(); + GTreeNode modelRoot = tree.getModelRoot(); + DomainFolder rootFolder = env.getRootFolder(); + + DomainFolder aFolder = rootFolder.createFolder("A"); + + // file link created before referenced file + aFolder.createLinkFile(rootFolder.getProjectData(), "/A/x", true, "y", + ProgramLinkContentHandler.INSTANCE); + + env.waitForTree(); + + Swing.runNow(() -> tree.expandPath(modelRoot.getChild("A"))); + env.waitForTree(); + + // Add file 'x' before folder A and is expanded and linked-folder B is not + aFolder.createFile("x", program, TaskMonitor.DUMMY); + program.release(this); + program = null; + + env.waitForTree(); + + rootFolder.createLinkFile(rootFolder.getProjectData(), "/A", false, "B", + FolderLinkContentHandler.INSTANCE); + env.waitForTree(); + + DomainFileNode bFolderLinkNode = (DomainFileNode) modelRoot.getChild("B"); + Swing.runNow(() -> tree.expandPath(bFolderLinkNode)); + env.waitForTree(); + + // + // Verify good state after everything created + // + // /A + // x + // y -> x + // /B -> /A (linked-folder) + // x + // y -> x + // + + DomainFolderNode aFolderNode = (DomainFolderNode) modelRoot.getChild("A"); + DomainFileNode xNode = (DomainFileNode) aFolderNode.getChild("x"); + assertNotNull(xNode); + + DomainFileNode yNode = (DomainFileNode) aFolderNode.getChild("y"); + assertNotNull(yNode); + waitForRefresh(yNode); + + String tip = yNode.getToolTip(); + assertFalse(tip.contains("Broken")); + + xNode = (DomainFileNode) aFolderNode.getChild("x"); + assertNotNull(xNode); + + yNode = (DomainFileNode) bFolderLinkNode.getChild("y"); + assertNotNull(yNode); + waitForRefresh(yNode); + + tip = yNode.getToolTip(); + assertFalse(tip.contains("Broken")); + + // Remove 'x' file and verify broken links are reflected + + xNode = (DomainFileNode) aFolderNode.getChild("x"); + assertNotNull(xNode); + + xNode.getDomainFile().delete(); + + env.waitForTree(); + + assertNull(aFolderNode.getChild("x")); + + yNode = (DomainFileNode) aFolderNode.getChild("y"); + assertNotNull(yNode); + waitForRefresh(yNode); + + tip = yNode.getToolTip(); + assertTrue(tip.contains("Broken")); + + xNode = (DomainFileNode) aFolderNode.getChild("x"); + assertNull(xNode); + + yNode = (DomainFileNode) bFolderLinkNode.getChild("y"); + assertNotNull(yNode); + waitForRefresh(yNode); + + tip = yNode.getToolTip(); + assertTrue(tip.contains("Broken")); + + } + + @Test + public void testLinkFileUpdate2() throws Exception { + + GTree tree = env.getTree(); + GTreeNode modelRoot = tree.getModelRoot(); + DomainFolder rootFolder = env.getRootFolder(); + + DomainFolder aFolder = rootFolder.createFolder("A"); + + // file link created before referenced file + aFolder.createLinkFile(rootFolder.getProjectData(), "/A/x", true, "y", + ProgramLinkContentHandler.INSTANCE); + + rootFolder.createLinkFile(rootFolder.getProjectData(), "/A", false, "B", + FolderLinkContentHandler.INSTANCE); + env.waitForTree(); + + DomainFileNode bFolderLinkNode = (DomainFileNode) modelRoot.getChild("B"); + Swing.runNow(() -> tree.expandPath(bFolderLinkNode)); + env.waitForTree(); + + // Add file 'x' while linked-folder B is expanded and folder A is not + DomainFile xFile = aFolder.createFile("x", program, TaskMonitor.DUMMY); + program.release(this); + program = null; + env.waitForTree(); + + //// Verify good state after everything created (leave A collapsed) + + DomainFileNode xNode = (DomainFileNode) bFolderLinkNode.getChild("x"); + assertNotNull(xNode); + + DomainFileNode yNode = (DomainFileNode) bFolderLinkNode.getChild("y"); + assertNotNull(yNode); + waitForRefresh(yNode); + + String tip = yNode.getToolTip(); + assertFalse(tip.contains("Broken")); + + //// Remove 'x' file + + xFile.delete(); + + env.waitForTree(); + + assertNull(bFolderLinkNode.getChild("x")); + + yNode = (DomainFileNode) bFolderLinkNode.getChild("y"); + assertNotNull(yNode); + waitForRefresh(yNode); + + tip = yNode.getToolTip(); + assertTrue(tip.contains("Broken")); + + } + + @Test + public void testLinkFileUpdate3() throws Exception { + + GTree tree = env.getTree(); + GTreeNode modelRoot = tree.getModelRoot(); + DomainFolder rootFolder = env.getRootFolder(); + + rootFolder.createLinkFile(rootFolder.getProjectData(), "/usr/bin", false, "bin", + FolderLinkContentHandler.INSTANCE); + + DomainFolder usrBinFolder = rootFolder.createFolder("usr").createFolder("bin"); + + env.waitForTree(); + + Swing.runNow(() -> tree.expandPath(modelRoot.getChild("usr"))); + env.waitForTree(); + + Swing.runNow(() -> tree.expandPath(modelRoot.getChild("usr").getChild("bin"))); + env.waitForTree(); + + Swing.runNow(() -> tree.expandPath(modelRoot.getChild("bin"))); + env.waitForTree(); + + // Add file 'bash' + DomainFile bashFile = usrBinFolder.createFile("bash", program, TaskMonitor.DUMMY); + program.release(this); + program = null; + env.waitForTree(); + + DomainFileNode binFolderLinkNode = (DomainFileNode) modelRoot.getChild("bin"); + assertNotNull(binFolderLinkNode.getChild("bash")); + + // + // /bin -> /usr/bin (linked folder) + // bash + // /usr + // /bin + // bash + // + + // Delete real folders and content + bashFile.delete(); + usrBinFolder.delete(); // /usr/bin + rootFolder.getFolder("usr").delete(); + + env.waitForTree(); + + assertNull(binFolderLinkNode.getChild("bash")); + + waitForRefresh(binFolderLinkNode); + env.waitForTree(); + + String tip = binFolderLinkNode.getToolTip(); + assertTrue(tip.contains("Broken")); + +// binLinkFile.delete(); + env.waitForTree(); + + // Re-create content + + rootFolder.createLinkFile(rootFolder.getProjectData(), "/usr/bin", false, "bin", + FolderLinkContentHandler.INSTANCE); + + usrBinFolder = rootFolder.createFolder("usr").createFolder("bin"); + + env.waitForTree(); + + Swing.runNow(() -> tree.expandPath(modelRoot.getChild("usr"))); + env.waitForTree(); + + Swing.runNow(() -> tree.expandPath(modelRoot.getChild("usr").getChild("bin"))); + env.waitForTree(); + + Swing.runNow(() -> tree.expandPath(modelRoot.getChild("bin"))); + env.waitForTree(); + + program = (Program) programAFile.getDomainObject(this, false, false, TaskMonitor.DUMMY); + assertNotNull(program); + usrBinFolder.createFile("bash", program, TaskMonitor.DUMMY); + program.release(this); + program = null; + + env.waitForTree(); + + DomainFileNode xLinkedFileNode = (DomainFileNode) binFolderLinkNode.getChild("bash"); + assertNotNull(xLinkedFileNode); + + tip = binFolderLinkNode.getToolTip(); + assertFalse(tip.contains("Broken")); + + // Repeat removal of folder A and its contents + bashFile = usrBinFolder.getFile("bash"); + assertNotNull(bashFile); + bashFile.delete(); + usrBinFolder.delete(); + rootFolder.getFolder("usr").delete(); + + env.waitForTree(); + + assertNull(binFolderLinkNode.getChild("bash")); + + waitForRefresh(binFolderLinkNode); + env.waitForTree(); + + tip = binFolderLinkNode.getToolTip(); + assertTrue(tip.contains("Broken")); + } + + private void waitForRefresh(DomainFileNode fileNode) { + waitFor(new BooleanSupplier() { + @Override + public boolean getAsBoolean() { + return !fileNode.hasPendingRefresh(); + } + }); + } +} diff --git a/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/datatree/ProjectLinkFileStatusTest.java b/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/datatree/ProjectLinkFileStatusTest.java index e1b7fe1c02..95fa029c86 100644 --- a/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/datatree/ProjectLinkFileStatusTest.java +++ b/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/datatree/ProjectLinkFileStatusTest.java @@ -32,6 +32,7 @@ import ghidra.program.database.ProgramLinkContentHandler; import ghidra.program.model.listing.Program; import ghidra.server.remote.ServerTestUtil; import ghidra.test.*; +import ghidra.util.Swing; import ghidra.util.exception.DuplicateFileException; import ghidra.util.task.TaskMonitor; @@ -50,13 +51,16 @@ public class ProjectLinkFileStatusTest extends AbstractGhidraHeadedIntegrationTe /** /abc/ (folder) - abc -> /xyz/abc (circular) + abc -> /xyz/abc (circular folder allowed as internal) foo (program file) /xyz/ abc -> /abc (folder link) - abc -> (circular) + abc -> /xyz/abc (circular folder allowed as internal) foo foo -> /abc/foo (program link) + /e -> f (circular folder link path) + /f -> g (circular folder link path) + /g -> e (circular folder link path) **/ DomainFolder rootFolder = env.getRootFolder(); @@ -72,6 +76,18 @@ public class ProjectLinkFileStatusTest extends AbstractGhidraHeadedIntegrationTe programFile.copyToAsLink(xyzFolder, false); + // Circular folder-link path without real folder + rootFolder.createLinkFile(rootFolder.getProjectData(), "/f", true, "e", + FolderLinkContentHandler.INSTANCE); + rootFolder.createLinkFile(rootFolder.getProjectData(), "/g", true, "f", + FolderLinkContentHandler.INSTANCE); + rootFolder.createLinkFile(rootFolder.getProjectData(), "/e", true, "g", + FolderLinkContentHandler.INSTANCE); + + rootFolder.createLinkFile(rootFolder.getProjectData(), + "/home/tsharr2/Examples/linktest/usr/lib64/../lib64", true, "nested2lib64", + FolderLinkContentHandler.INSTANCE); + env.waitForTree(); } @@ -257,23 +273,154 @@ public class ProjectLinkFileStatusTest extends AbstractGhidraHeadedIntegrationTe } @Test - public void testBrokenFolderLink() throws Exception { + public void testCircularFolderLink1() throws Exception { // - // Verify broken folder-link status for /abc/abc which has circular reference + // Verify broken folder-link status for /e which has circular reference + // + DomainFileNode eLinkNode = waitForFileNode("/e"); + assertTrue(eLinkNode.isFolderLink()); + String displayName = runSwing(() -> eLinkNode.getDisplayText()); + assertTrue("Unexpected node display name: " + displayName, displayName.endsWith(" f")); + assertEquals(LinkStatus.BROKEN, + LinkHandler.getLinkFileStatus(eLinkNode.getDomainFile(), null)); + String tooltip = eLinkNode.getToolTip().replace(" ", " "); + assertTrue(tooltip.contains("circular")); + + // + // Verify broken folder-link status for /g which has circular reference + // + DomainFileNode gLinkNode = waitForFileNode("/g"); + assertTrue(gLinkNode.isFolderLink()); + displayName = runSwing(() -> gLinkNode.getDisplayText()); + assertTrue("Unexpected node display name: " + displayName, displayName.endsWith(" e")); + assertEquals(LinkStatus.BROKEN, + LinkHandler.getLinkFileStatus(gLinkNode.getDomainFile(), null)); + tooltip = gLinkNode.getToolTip().replace(" ", " "); + assertTrue(tooltip.contains("circular")); + + // + // Verify broken folder-link status for /f which has circular reference + // + DomainFileNode fLinkNode = waitForFileNode("/f"); + assertTrue(fLinkNode.isFolderLink()); + displayName = runSwing(() -> fLinkNode.getDisplayText()); + assertTrue("Unexpected node display name: " + displayName, displayName.endsWith(" g")); + assertEquals(LinkStatus.BROKEN, + LinkHandler.getLinkFileStatus(fLinkNode.getDomainFile(), null)); + tooltip = fLinkNode.getToolTip().replace(" ", " "); + assertTrue(tooltip.contains("circular")); + + // + // Rename folder /e to /ABC causing folder-links to have broken path + // + Swing.runNow(() -> eLinkNode.setName("ABC")); + + env.waitForTree(); // give time for ChangeManager to update + + // Verify /e node not found + assertNull(env.getRootNode().getChild("e")); + + // + // Verify broken folder-link status for /ABC (final folder /e not found) + // + DomainFileNode abcLinkNode = waitForFileNode("/ABC"); + assertTrue(abcLinkNode.isFolderLink()); + displayName = runSwing(() -> abcLinkNode.getDisplayText()); + assertTrue("Unexpected node display name: " + displayName, displayName.endsWith(" f")); + assertEquals(LinkStatus.BROKEN, + LinkHandler.getLinkFileStatus(abcLinkNode.getDomainFile(), null)); + tooltip = abcLinkNode.getToolTip().replace(" ", " "); + assertTrue(tooltip.contains("folder not found: /e")); + + // + // Verify broken folder-link status for /g (final folder /e not found) + // + waitForFileNode("/g"); + assertTrue(gLinkNode.isFolderLink()); + displayName = runSwing(() -> gLinkNode.getDisplayText()); + assertTrue("Unexpected node display name: " + displayName, displayName.endsWith(" e")); + assertEquals(LinkStatus.BROKEN, + LinkHandler.getLinkFileStatus(gLinkNode.getDomainFile(), null)); + tooltip = gLinkNode.getToolTip().replace(" ", " "); + assertTrue(tooltip.contains("folder not found: /e")); + + // + // Verify broken folder-link status for /f (final folder /e not found) + // + waitForFileNode("/f"); + assertTrue(fLinkNode.isFolderLink()); + displayName = runSwing(() -> fLinkNode.getDisplayText()); + assertTrue("Unexpected node display name: " + displayName, displayName.endsWith(" g")); + assertEquals(LinkStatus.BROKEN, + LinkHandler.getLinkFileStatus(fLinkNode.getDomainFile(), null)); + tooltip = fLinkNode.getToolTip().replace(" ", " "); + assertTrue(tooltip.contains("folder not found: /e")); + + // + // Create folder /e + // + DomainFolder rootFolder = env.getRootFolder(); + rootFolder.createFolder("e"); + + env.waitForTree(); // give time for ChangeManager to update + + // + // Verify good folder-link status for /ABC + // + waitForFileNode("/ABC"); + assertTrue(abcLinkNode.isFolderLink()); + displayName = runSwing(() -> abcLinkNode.getDisplayText()); + assertTrue("Unexpected node display name: " + displayName, displayName.endsWith(" f")); + assertEquals(LinkStatus.INTERNAL, + LinkHandler.getLinkFileStatus(abcLinkNode.getDomainFile(), null)); + tooltip = gLinkNode.getToolTip().replace(" ", " "); + assertFalse(tooltip.contains("folder not found")); + assertFalse(tooltip.contains("circular")); + + // + // Verify good folder-link status for /g + // + waitForFileNode("/g"); + assertTrue(gLinkNode.isFolderLink()); + displayName = runSwing(() -> gLinkNode.getDisplayText()); + assertTrue("Unexpected node display name: " + displayName, displayName.endsWith(" e")); + assertEquals(LinkStatus.INTERNAL, + LinkHandler.getLinkFileStatus(gLinkNode.getDomainFile(), null)); + tooltip = gLinkNode.getToolTip().replace(" ", " "); + assertFalse(tooltip.contains("folder not found")); + assertFalse(tooltip.contains("circular")); + + // + // Verify good folder-link status for /f + // + waitForFileNode("/f"); + assertTrue(fLinkNode.isFolderLink()); + displayName = runSwing(() -> fLinkNode.getDisplayText()); + assertTrue("Unexpected node display name: " + displayName, displayName.endsWith(" g")); + assertEquals(LinkStatus.INTERNAL, + LinkHandler.getLinkFileStatus(fLinkNode.getDomainFile(), null)); + tooltip = fLinkNode.getToolTip().replace(" ", " "); + assertFalse(tooltip.contains("folder not found")); + assertFalse(tooltip.contains("circular")); + } + + @Test + public void testCircularFolderLink2() throws Exception { + + // + // Verify good folder-link internal status for /abc/abc which has allowed circular reference // DomainFileNode abcAbcLinkNode = waitForFileNode("/abc/abc"); assertTrue(abcAbcLinkNode.isFolderLink()); String displayName = runSwing(() -> abcAbcLinkNode.getDisplayText()); assertTrue("Unexpected node display name: " + displayName, displayName.endsWith(" /xyz/abc")); - assertEquals(LinkStatus.BROKEN, + assertEquals(LinkStatus.INTERNAL, LinkHandler.getLinkFileStatus(abcAbcLinkNode.getDomainFile(), null)); - String tooltip = abcAbcLinkNode.getToolTip().replace(" ", " "); - assertTrue(tooltip.contains("circular")); // - // Verify good folder-link internal status for /xyz/abc which has circular reference + // Verify good folder-link internal status for /xyz/abc which has allowed circular reference // DomainFileNode xyzAbcLinkNode = waitForFileNode("/xyz/abc"); assertTrue(xyzAbcLinkNode.isFolderLink()); @@ -283,17 +430,15 @@ public class ProjectLinkFileStatusTest extends AbstractGhidraHeadedIntegrationTe LinkHandler.getLinkFileStatus(xyzAbcLinkNode.getDomainFile(), null)); // - // Verify broken folder-link status for /xyz/abc/abc which has circular reference + // Verify good folder-link internal status for /xyz/abc/abc which has allowed circular reference // DomainFileNode abcLinkedNode = waitForFileNode("/xyz/abc/abc"); assertTrue(abcLinkedNode.isFolderLink()); displayName = runSwing(() -> abcLinkedNode.getDisplayText()); assertTrue("Unexpected node display name: " + displayName, displayName.endsWith(" /xyz/abc")); - assertEquals(LinkStatus.BROKEN, + assertEquals(LinkStatus.INTERNAL, LinkHandler.getLinkFileStatus(abcLinkedNode.getDomainFile(), null)); - tooltip = abcLinkedNode.getToolTip().replace(" ", " "); - assertTrue(tooltip.contains("circular")); // // Rename folder /abc to /ABC causing folder-link /xyz/abc to become broken @@ -315,7 +460,7 @@ public class ProjectLinkFileStatusTest extends AbstractGhidraHeadedIntegrationTe displayName.endsWith(" /xyz/abc")); assertEquals(LinkStatus.BROKEN, LinkHandler.getLinkFileStatus(ABCAbcLinkNode.getDomainFile(), null)); - tooltip = ABCAbcLinkNode.getToolTip().replace(" ", " "); + String tooltip = ABCAbcLinkNode.getToolTip().replace(" ", " "); assertTrue(tooltip.contains("folder not found: /abc")); env.waitForTree(); // give time for ChangeManager to update