From 5a422c4502bff381bee917247478623014c606f0 Mon Sep 17 00:00:00 2001 From: ghidra1 Date: Tue, 30 Aug 2022 18:31:11 -0400 Subject: [PATCH] GP-2509 GP-2644 Improved Ghidra URL support. Added support for Ghidra URL linked files and folders within project. --- .../core/debug/DebuggerCoordinates.java | 4 +- .../DebuggerTraceManagerServicePlugin.java | 13 +- .../service/url/ProjectExperimentsTest.java | 2 +- .../trace/database/DBTraceContentHandler.java | 32 +- .../database/DBTraceLinkContentHandler.java | 71 +++ Ghidra/Features/Base/certification.manifest | 2 +- .../CallAnotherScriptForAllPrograms.java | 8 +- .../RepositoryFileUpgradeScript.java | 2 + .../ghidra_scripts/VersionControl_AddAll.java | 5 +- .../help/topics/Annotations/Annotations.html | 45 +- .../FrontEndPlugin/Ghidra_Front_end.htm | 48 ++ .../images/LinkOtherProject.png | Bin 0 -> 37226 bytes .../images/ViewOtherProjects.png | Bin 30835 -> 37159 bytes .../core/calltree/CallTreeProvider.java | 9 +- .../core/clear/ClearFlowAndRepairCmd.java | 26 +- .../plugin/core/comments/CommentsDialog.java | 9 +- .../core/datamgr/DataTypeManagerPlugin.java | 11 +- .../core/datamgr/DataTypesProvider.java | 11 +- .../associate/AssociateDataTypeAction.java | 31 +- .../archive/DataTypeManagerHandler.java | 24 +- .../core/datamgr/util/DataTypeUtils.java | 14 +- .../plugin/core/exporter/ExporterDialog.java | 22 +- .../misc/MyProgramChangesDisplayPlugin.java | 3 +- .../processors/LanguageProviderPlugin.java | 26 +- .../core/progmgr/MultiProgramManager.java | 119 ++-- .../core/progmgr/ProgramManagerPlugin.java | 308 +++++----- .../core/progmgr/ProgramSaveManager.java | 17 +- .../app/util/headless/HeadlessAnalyzer.java | 13 +- .../ghidra/app/util/task/OpenProgramTask.java | 295 +++++++--- .../app/util/viewer/field/Annotation.java | 36 +- .../GhidraLocalURLAnnotatedStringHandler.java | 34 ++ ...GhidraServerURLAnnotatedStringHandler.java | 34 ++ .../field/URLAnnotatedStringHandler.java | 4 +- .../ghidra/base/project/GhidraProject.java | 7 + .../ghidra/framework/main/DataTreeDialog.java | 21 +- .../src/main/java/ghidra/test/TestEnv.java | 69 ++- .../Base/src/main/resources/images/link.png | Bin 410 -> 0 bytes .../app/util/viewer/field/AnnotationTest.java | 99 +++- .../data/ProjectFileManagerTest.java | 7 +- .../framework/main/DataTreeDialogTest.java | 9 +- .../project/AddViewToProjectTest.java | 4 +- .../project/CreateDomainObjectTest.java | 2 +- .../ghidra/base/project/FakeRepository.java | 37 +- .../base/project/FakeSharedProject.java | 26 +- .../CollectFailedRelocations.java | 4 + .../CreateMultipleLibraries.java | 4 + .../ghidra_scripts/FidStatistics.java | 4 + .../FunctionID/ghidra_scripts/FindErrors.java | 11 +- .../ghidra_scripts/FindFunctionByHash.java | 4 + .../ghidra_scripts/FindNamedFunction.java | 10 +- .../ghidra/feature/fid/plugin/IngestTask.java | 4 + .../ghidra/feature/vt/api/db/VTSessionDB.java | 4 +- .../vt/api/impl/VTSessionContentHandler.java | 15 +- .../OpenVersionTrackingSessionAction.java | 13 +- .../vt/gui/wizard/NewSessionPanel.java | 5 +- .../feature/vt/gui/wizard/VTWizardUtils.java | 23 +- .../java/db/buffers/BufferFileAdapter.java | 7 +- .../java/docking/widgets/tree/GTreeNode.java | 26 + .../tree/tasks/GTreeExpandAllTask.java | 21 +- .../framework/client/RepositoryAdapter.java | 6 +- .../client/RepositoryNotFoundException.java | 31 + .../client/RepositoryServerAdapter.java | 25 +- .../framework/remote/RepositoryHandle.java | 2 +- .../store/FileSystemEventManager.java | 105 ++-- .../store/local/IndexedLocalFileSystem.java | 36 +- .../store/local/IndexedV1LocalFileSystem.java | 3 +- .../store/local/LocalFileSystem.java | 57 +- .../local/AbstractLocalFileSystemTest.java | 7 +- .../generic/test/AbstractGenericTest.java | 35 ++ .../java/generic/util/FileChannelLock.java | 6 +- .../Framework/Project/certification.manifest | 1 + .../data/project.icons.theme.properties | 6 +- .../ghidra/framework/data/ContentHandler.java | 64 +-- .../framework/data/DBContentHandler.java | 109 +--- .../data/DBWithUserDataContentHandler.java | 144 +++++ .../framework/data/DomainFileProxy.java | 72 ++- .../framework/data/DomainObjectAdapter.java | 30 +- .../data/FolderLinkContentHandler.java | 119 ++++ .../ghidra/framework/data/GhidraFile.java | 89 ++- .../ghidra/framework/data/GhidraFileData.java | 295 ++++++---- .../ghidra/framework/data/GhidraFolder.java | 86 ++- .../framework/data/GhidraFolderData.java | 96 +++- .../ghidra/framework/data/LinkHandler.java | 210 +++++++ .../framework/data/LinkedGhidraFile.java | 426 ++++++++++++++ .../framework/data/LinkedGhidraFolder.java | 138 +++++ .../framework/data/LinkedGhidraSubFolder.java | 294 ++++++++++ .../framework/data/ProjectFileManager.java | 301 +++++++--- .../{project => data}/ProjectLock.java | 2 +- .../ghidra/framework/data/URLLinkObject.java | 101 ++++ .../ghidra/framework/main/FrontEndPlugin.java | 81 ++- .../framework/main/ProjectActionManager.java | 13 +- .../framework/main/ProjectDataPanel.java | 48 +- .../main/datatable/ProjectDataTablePanel.java | 9 +- .../main/datatable/ProjectTreeAction.java | 2 +- .../main/datatree/ChangeManager.java | 38 +- .../framework/main/datatree/CheckInTask.java | 2 +- .../main/datatree/DomainFileNode.java | 58 +- .../main/datatree/DomainFolderNode.java | 41 +- .../main/datatree/ProjectDataTreePanel.java | 3 + .../main/datatree/VersionControlDialog.java | 14 +- .../main/datatree/VersionControlTask.java | 12 +- .../actions/ProjectDataCopyAction.java | 4 - .../actions/ProjectDataNewFolderAction.java | 5 + .../actions/ProjectDataPasteLinkAction.java | 147 +++++ .../actions/VersionControlAddAction.java | 4 +- .../actions/VersionControlCheckInAction.java | 2 +- .../ghidra/framework/model/DomainFile.java | 98 +++- .../framework/model/DomainFileFilter.java | 10 +- .../ghidra/framework/model/DomainFolder.java | 46 +- .../framework/model/LinkedDomainFile.java | 33 ++ .../framework/model/LinkedDomainFolder.java | 44 ++ .../java/ghidra/framework/model/Project.java | 29 +- .../ghidra/framework/model/ProjectData.java | 13 +- .../framework/model/ProjectLocator.java | 76 ++- .../framework/model/ProjectViewListener.java | 42 ++ .../ghidra/framework/model/ToolServices.java | 54 +- .../ghidra/framework/plugintool/Plugin.java | 12 + .../framework/plugintool/PluginManager.java | 18 + .../framework/plugintool/PluginTool.java | 13 + .../plugintool/ToolServicesAdapter.java | 16 + .../framework/project/DefaultProject.java | 276 +++++---- .../project/tool/GhidraToolTemplate.java | 2 +- .../project/tool/ToolServicesImpl.java | 142 +++-- .../DefaultGhidraProtocolConnector.java | 41 +- .../ghidra/DefaultGhidraProtocolHandler.java | 7 +- .../DefaultLocalGhidraProtocolConnector.java | 51 +- .../ghidra/GetUrlContentTypeTask.java | 113 ++++ .../ghidra/GhidraProtocolConnector.java | 109 ++-- .../framework/protocol/ghidra/GhidraURL.java | 530 +++++++++++++----- .../protocol/ghidra/GhidraURLConnection.java | 160 ++++-- .../ghidra/GhidraURLWrappedContent.java | 42 +- .../protocol/ghidra/RepositoryInfo.java | 8 + .../protocol/ghidra/TransientProjectData.java | 4 +- .../ghidra/TransientProjectManager.java | 26 +- .../util/exception/BadLinkException.java | 30 + .../src/main/resources/images/link.png | Bin 0 -> 2913 bytes .../framework/model/TestDummyDomainFile.java | 31 +- .../model/TestDummyDomainFolder.java | 11 + .../framework/model/TestDummyProjectData.java | 7 - .../DataTypeArchiveContentHandler.java | 37 +- .../DataTypeArchiveLinkContentHandler.java | 71 +++ .../database/ProgramContentHandler.java | 34 +- .../database/ProgramLinkContentHandler.java | 71 +++ .../program/database/ProgramUserDataDB.java | 8 +- .../screenshot/FrontEndPluginScreenShots.java | 57 +- .../screenshot/VersionControlScreenShots.java | 1 - .../framework/main/LaunchUrlInToolTest.java | 343 ++++++++++++ .../ghidra/server/remote/ServerTestUtil.java | 66 ++- .../java/ghidra/test/DataTreeHelper.java | 152 +++++ .../java/ghidra/test/FrontEndTestEnv.java | 84 +-- ...faultGhidraProtocolConnectorParseTest.java | 128 +++++ ...LocalGhidraProtocolConnectorParseTest.java | 77 +++ .../protocol/ghidra/GhidraURLTest.java | 499 +++++++++++++++++ 153 files changed, 7083 insertions(+), 1732 deletions(-) create mode 100644 Ghidra/Debug/Framework-TraceModeling/src/main/java/ghidra/trace/database/DBTraceLinkContentHandler.java create mode 100644 Ghidra/Features/Base/src/main/help/help/topics/FrontEndPlugin/images/LinkOtherProject.png create mode 100644 Ghidra/Features/Base/src/main/java/ghidra/app/util/viewer/field/GhidraLocalURLAnnotatedStringHandler.java create mode 100644 Ghidra/Features/Base/src/main/java/ghidra/app/util/viewer/field/GhidraServerURLAnnotatedStringHandler.java delete mode 100644 Ghidra/Features/Base/src/main/resources/images/link.png create mode 100644 Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/client/RepositoryNotFoundException.java create mode 100644 Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DBWithUserDataContentHandler.java create mode 100644 Ghidra/Framework/Project/src/main/java/ghidra/framework/data/FolderLinkContentHandler.java create mode 100644 Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkHandler.java create mode 100644 Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkedGhidraFile.java create mode 100644 Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkedGhidraFolder.java create mode 100644 Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkedGhidraSubFolder.java rename Ghidra/Framework/Project/src/main/java/ghidra/framework/{project => data}/ProjectLock.java (97%) create mode 100644 Ghidra/Framework/Project/src/main/java/ghidra/framework/data/URLLinkObject.java create mode 100644 Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/ProjectDataPasteLinkAction.java create mode 100644 Ghidra/Framework/Project/src/main/java/ghidra/framework/model/LinkedDomainFile.java create mode 100644 Ghidra/Framework/Project/src/main/java/ghidra/framework/model/LinkedDomainFolder.java create mode 100644 Ghidra/Framework/Project/src/main/java/ghidra/framework/model/ProjectViewListener.java create mode 100644 Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/GetUrlContentTypeTask.java create mode 100644 Ghidra/Framework/Project/src/main/java/ghidra/util/exception/BadLinkException.java create mode 100644 Ghidra/Framework/Project/src/main/resources/images/link.png create mode 100644 Ghidra/Framework/SoftwareModeling/src/main/java/ghidra/program/database/DataTypeArchiveLinkContentHandler.java create mode 100644 Ghidra/Framework/SoftwareModeling/src/main/java/ghidra/program/database/ProgramLinkContentHandler.java create mode 100644 Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/LaunchUrlInToolTest.java create mode 100644 Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/test/DataTreeHelper.java create mode 100644 Ghidra/Test/IntegrationTest/src/test/java/ghidra/framework/protocol/ghidra/DefaultGhidraProtocolConnectorParseTest.java create mode 100644 Ghidra/Test/IntegrationTest/src/test/java/ghidra/framework/protocol/ghidra/DefaultLocalGhidraProtocolConnectorParseTest.java create mode 100644 Ghidra/Test/IntegrationTest/src/test/java/ghidra/framework/protocol/ghidra/GhidraURLTest.java diff --git a/Ghidra/Debug/Debugger/src/main/java/ghidra/app/plugin/core/debug/DebuggerCoordinates.java b/Ghidra/Debug/Debugger/src/main/java/ghidra/app/plugin/core/debug/DebuggerCoordinates.java index e556bd5631..01fd9f49d1 100644 --- a/Ghidra/Debug/Debugger/src/main/java/ghidra/app/plugin/core/debug/DebuggerCoordinates.java +++ b/Ghidra/Debug/Debugger/src/main/java/ghidra/app/plugin/core/debug/DebuggerCoordinates.java @@ -27,6 +27,7 @@ import ghidra.framework.data.ProjectFileManager; import ghidra.framework.model.*; import ghidra.framework.options.SaveState; import ghidra.framework.plugintool.PluginTool; +import ghidra.framework.store.LockException; import ghidra.trace.database.DBTraceContentHandler; import ghidra.trace.model.Lifespan; import ghidra.trace.model.Trace; @@ -643,6 +644,7 @@ public class DebuggerCoordinates { ProjectData projData = tool.getProject().getProjectData(projLoc); if (projData == null) { try { + // FIXME! orphaned instance - transient in nature projData = new ProjectFileManager(projLoc, false, false); } catch (NotOwnerException e) { @@ -650,7 +652,7 @@ public class DebuggerCoordinates { "Not project owner: " + projLoc + "(" + pathname + ")"); return null; } - catch (IOException e) { + catch (IOException | LockException e) { Msg.showError(DebuggerCoordinates.class, tool.getToolFrame(), "Trace Open Failed", "Project error: " + e.getMessage()); return null; diff --git a/Ghidra/Debug/Debugger/src/main/java/ghidra/app/plugin/core/debug/service/tracemgr/DebuggerTraceManagerServicePlugin.java b/Ghidra/Debug/Debugger/src/main/java/ghidra/app/plugin/core/debug/service/tracemgr/DebuggerTraceManagerServicePlugin.java index d91aaacd57..3a81e82f92 100644 --- a/Ghidra/Debug/Debugger/src/main/java/ghidra/app/plugin/core/debug/service/tracemgr/DebuggerTraceManagerServicePlugin.java +++ b/Ghidra/Debug/Debugger/src/main/java/ghidra/app/plugin/core/debug/service/tracemgr/DebuggerTraceManagerServicePlugin.java @@ -368,7 +368,18 @@ public class DebuggerTraceManagerServicePlugin extends Plugin if (traceChooserDialog != null) { return traceChooserDialog; } - DomainFileFilter filter = df -> Trace.class.isAssignableFrom(df.getDomainObjectClass()); + DomainFileFilter filter = new DomainFileFilter() { + + @Override + public boolean accept(DomainFile df) { + return Trace.class.isAssignableFrom(df.getDomainObjectClass()); + } + + @Override + public boolean followLinkedFolders() { + return false; + } + }; // TODO regarding the hack note below, I believe this issue ahs been fixed, but not sure how to test return traceChooserDialog = diff --git a/Ghidra/Debug/Debugger/src/test/java/ghidra/app/plugin/core/debug/service/url/ProjectExperimentsTest.java b/Ghidra/Debug/Debugger/src/test/java/ghidra/app/plugin/core/debug/service/url/ProjectExperimentsTest.java index 7234a74d4f..e0c6d45e19 100644 --- a/Ghidra/Debug/Debugger/src/test/java/ghidra/app/plugin/core/debug/service/url/ProjectExperimentsTest.java +++ b/Ghidra/Debug/Debugger/src/test/java/ghidra/app/plugin/core/debug/service/url/ProjectExperimentsTest.java @@ -176,7 +176,7 @@ public class ProjectExperimentsTest extends AbstractGhidraHeadedIntegrationTest assertNotNull(proj2 = pm.openProject(loc2, false, false)); - ProjectData data1 = proj2.addProjectView(loc1.getURL()); + ProjectData data1 = proj2.addProjectView(loc1.getURL(), true); assertNotNull(data1); // It's a cryin' shame. I don't get *any* callbacks. _ANY!_ diff --git a/Ghidra/Debug/Framework-TraceModeling/src/main/java/ghidra/trace/database/DBTraceContentHandler.java b/Ghidra/Debug/Framework-TraceModeling/src/main/java/ghidra/trace/database/DBTraceContentHandler.java index 8030c22464..f3018fbae1 100644 --- a/Ghidra/Debug/Framework-TraceModeling/src/main/java/ghidra/trace/database/DBTraceContentHandler.java +++ b/Ghidra/Debug/Framework-TraceModeling/src/main/java/ghidra/trace/database/DBTraceContentHandler.java @@ -18,11 +18,13 @@ package ghidra.trace.database; import java.io.IOException; import javax.swing.Icon; +import javax.swing.ImageIcon; import db.DBHandle; import db.buffers.BufferFile; import db.buffers.ManagedBufferFile; -import ghidra.framework.data.*; +import ghidra.framework.data.DBWithUserDataContentHandler; +import ghidra.framework.data.DomainObjectMergeManager; import ghidra.framework.model.ChangeSet; import ghidra.framework.model.DomainObject; import ghidra.framework.store.*; @@ -34,9 +36,16 @@ import ghidra.util.exception.CancelledException; import ghidra.util.exception.VersionException; import ghidra.util.task.TaskMonitor; -public class DBTraceContentHandler extends DBContentHandler { +public class DBTraceContentHandler extends DBWithUserDataContentHandler { public static final String TRACE_CONTENT_TYPE = "Trace"; + public static ImageIcon TRACE_ICON = Trace.TRACE_ICON; + + static final Class TRACE_DOMAIN_OBJECT_CLASS = DBTrace.class; + static final String TRACE_CONTENT_DEFAULT_TOOL = "Debugger"; + + private static final DBTraceLinkContentHandler linkHandler = new DBTraceLinkContentHandler(); + @Override public long createFile(FileSystem fs, FileSystem userfs, String path, String name, DomainObject obj, TaskMonitor monitor) @@ -48,7 +57,7 @@ public class DBTraceContentHandler extends DBContentHandler { } @Override - public DomainObjectAdapter getImmutableObject(FolderItem item, Object consumer, int version, + public DBTrace getImmutableObject(FolderItem item, Object consumer, int version, int minChangeVersion, TaskMonitor monitor) throws IOException, CancelledException, VersionException { String contentType = item.getContentType(); @@ -96,7 +105,7 @@ public class DBTraceContentHandler extends DBContentHandler { } @Override - public DomainObjectAdapter getReadOnlyObject(FolderItem item, int version, boolean okToUpgrade, + public DBTrace getReadOnlyObject(FolderItem item, int version, boolean okToUpgrade, Object consumer, TaskMonitor monitor) throws IOException, VersionException, CancelledException { String contentType = item.getContentType(); @@ -146,7 +155,7 @@ public class DBTraceContentHandler extends DBContentHandler { } @Override - public DomainObjectAdapter getDomainObject(FolderItem item, FileSystem userfs, long checkoutId, + public DBTrace getDomainObject(FolderItem item, FileSystem userfs, long checkoutId, boolean okToUpgrade, boolean recover, Object consumer, TaskMonitor monitor) throws IOException, CancelledException, VersionException { String contentType = item.getContentType(); @@ -296,8 +305,8 @@ public class DBTraceContentHandler extends DBContentHandler { } @Override - public Class getDomainObjectClass() { - return DBTrace.class; + public Class getDomainObjectClass() { + return TRACE_DOMAIN_OBJECT_CLASS; } @Override @@ -312,12 +321,12 @@ public class DBTraceContentHandler extends DBContentHandler { @Override public String getDefaultToolName() { - return "Debugger"; + return TRACE_CONTENT_DEFAULT_TOOL; } @Override public Icon getIcon() { - return Trace.TRACE_ICON; + return TRACE_ICON; } @Override @@ -331,4 +340,9 @@ public class DBTraceContentHandler extends DBContentHandler { // TODO: return null; } + + @Override + public DBTraceLinkContentHandler getLinkHandler() { + return linkHandler; + } } diff --git a/Ghidra/Debug/Framework-TraceModeling/src/main/java/ghidra/trace/database/DBTraceLinkContentHandler.java b/Ghidra/Debug/Framework-TraceModeling/src/main/java/ghidra/trace/database/DBTraceLinkContentHandler.java new file mode 100644 index 0000000000..f64526be0a --- /dev/null +++ b/Ghidra/Debug/Framework-TraceModeling/src/main/java/ghidra/trace/database/DBTraceLinkContentHandler.java @@ -0,0 +1,71 @@ +/* ### + * 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.trace.database; + +import java.io.IOException; + +import javax.swing.Icon; + +import ghidra.framework.data.LinkHandler; +import ghidra.framework.data.URLLinkObject; +import ghidra.framework.model.DomainObject; +import ghidra.framework.store.FileSystem; +import ghidra.util.InvalidNameException; +import ghidra.util.exception.CancelledException; +import ghidra.util.task.TaskMonitor; + +public class DBTraceLinkContentHandler extends LinkHandler { + + public static final String TRACE_LINK_CONTENT_TYPE = "TraceLink"; + + @Override + public long createFile(FileSystem fs, FileSystem userfs, String path, String name, + DomainObject obj, TaskMonitor monitor) + throws IOException, InvalidNameException, CancelledException { + if (!(obj instanceof URLLinkObject)) { + throw new IOException("Unsupported domain object: " + obj.getClass().getName()); + } + return createFile((URLLinkObject) obj, TRACE_LINK_CONTENT_TYPE, fs, path, name, + monitor); + } + + @Override + public String getContentType() { + return TRACE_LINK_CONTENT_TYPE; + } + + @Override + public String getContentTypeDisplayString() { + return TRACE_LINK_CONTENT_TYPE; + } + + @Override + public Class getDomainObjectClass() { + // return linked content class + return DBTraceContentHandler.TRACE_DOMAIN_OBJECT_CLASS; + } + + @Override + public Icon getIcon() { + return DBTraceContentHandler.TRACE_ICON; + } + + @Override + public String getDefaultToolName() { + return DBTraceContentHandler.TRACE_CONTENT_DEFAULT_TOOL; + } + +} diff --git a/Ghidra/Features/Base/certification.manifest b/Ghidra/Features/Base/certification.manifest index d35661f6a9..195fbaad40 100644 --- a/Ghidra/Features/Base/certification.manifest +++ b/Ghidra/Features/Base/certification.manifest @@ -351,6 +351,7 @@ src/main/help/help/topics/FrontEndPlugin/images/DeleteProject.png||GHIDRA||||END src/main/help/help/topics/FrontEndPlugin/images/EditPluginPath.png||GHIDRA||||END| src/main/help/help/topics/FrontEndPlugin/images/EditProjectAccessList.png||GHIDRA||||END| src/main/help/help/topics/FrontEndPlugin/images/EditProjectAccessPanel.png||GHIDRA||||END| +src/main/help/help/topics/FrontEndPlugin/images/LinkOtherProject.png||GHIDRA||||END| src/main/help/help/topics/FrontEndPlugin/images/MemoryUsage.png||GHIDRA||||END| src/main/help/help/topics/FrontEndPlugin/images/NonSharedProjectInfo.png||GHIDRA||||END| src/main/help/help/topics/FrontEndPlugin/images/OpenProject.png||GHIDRA||||END| @@ -1089,7 +1090,6 @@ src/main/resources/images/layout_add.png||FAMFAMFAM Icons - CC 2.5|||famfamfam s src/main/resources/images/ledgreen.png||Nuvola Icons - LGPL 2.1|||Nuvola icon set|END| src/main/resources/images/ledred.png||Nuvola Icons - LGPL 2.1|||Nuvola icon set|END| src/main/resources/images/ledyellow.png||Nuvola Icons - LGPL 2.1|||Nuvola icon set|END| -src/main/resources/images/link.png||Nuvola Icons - LGPL 2.1|||Nuvola icon set|END| src/main/resources/images/lock.gif||GHIDRA||||END| src/main/resources/images/magnifier.png||FAMFAMFAM Icons - CC 2.5||||END| src/main/resources/images/media-flash.png||Oxygen Icons - LGPL 3.0|||Oxygen icon theme (dual license; LGPL or CC-SA-3.0)|END| diff --git a/Ghidra/Features/Base/ghidra_scripts/CallAnotherScriptForAllPrograms.java b/Ghidra/Features/Base/ghidra_scripts/CallAnotherScriptForAllPrograms.java index 4832a4be91..2394c1f664 100644 --- a/Ghidra/Features/Base/ghidra_scripts/CallAnotherScriptForAllPrograms.java +++ b/Ghidra/Features/Base/ghidra_scripts/CallAnotherScriptForAllPrograms.java @@ -17,6 +17,8 @@ // NOTE: Script will only process unversioned and checked-out files. //@category Examples +import java.io.IOException; + import ghidra.app.script.GhidraScript; import ghidra.app.script.GhidraState; import ghidra.framework.model.*; @@ -25,8 +27,6 @@ import ghidra.program.model.listing.Program; import ghidra.util.exception.CancelledException; import ghidra.util.exception.VersionException; -import java.io.IOException; - public class CallAnotherScriptForAllPrograms extends GhidraScript { // The script referenced in the following line should be replaced with the script to be called @@ -59,6 +59,10 @@ public class CallAnotherScriptForAllPrograms extends GhidraScript { } private void processDomainFile(DomainFile domainFile) throws CancelledException, IOException { + // Do not follow folder-links or consider program links. Using content type + // to filter is best way to control this. If program links should be considered + // "Program.class.isAssignableFrom(domainFile.getDomainObjectClass())" + // should be used. if (!ProgramContentHandler.PROGRAM_CONTENT_TYPE.equals(domainFile.getContentType())) { return; // skip non-Program files } diff --git a/Ghidra/Features/Base/ghidra_scripts/RepositoryFileUpgradeScript.java b/Ghidra/Features/Base/ghidra_scripts/RepositoryFileUpgradeScript.java index db4c0acaec..617aab04a8 100644 --- a/Ghidra/Features/Base/ghidra_scripts/RepositoryFileUpgradeScript.java +++ b/Ghidra/Features/Base/ghidra_scripts/RepositoryFileUpgradeScript.java @@ -143,6 +143,8 @@ public class RepositoryFileUpgradeScript extends GhidraScript { } private boolean performProgramUpgrade(DomainFile df) throws IOException, CancelledException { + // Do not follow folder-links or consider program links. Using content type + // to filter is best way to control this. if (!ProgramContentHandler.PROGRAM_CONTENT_TYPE.equals(df.getContentType())) { return false; } diff --git a/Ghidra/Features/Base/ghidra_scripts/VersionControl_AddAll.java b/Ghidra/Features/Base/ghidra_scripts/VersionControl_AddAll.java index c1f2f84d62..63db21645d 100644 --- a/Ghidra/Features/Base/ghidra_scripts/VersionControl_AddAll.java +++ b/Ghidra/Features/Base/ghidra_scripts/VersionControl_AddAll.java @@ -48,7 +48,10 @@ public class VersionControl_AddAll extends GhidraScript { if (monitor.isCancelled()) { break; } - + // Do not follow folder-links or consider program links. Using content type + // to filter is best way to control this. If program links should be considered + // "Program.class.isAssignableFrom(domainFile.getDomainObjectClass())" + // should be used. It may also be appropriate to handle other content types. if (!ProgramContentHandler.PROGRAM_CONTENT_TYPE.equals(file.getContentType()) || file.isVersioned()) { continue;// skip diff --git a/Ghidra/Features/Base/src/main/help/help/topics/Annotations/Annotations.html b/Ghidra/Features/Base/src/main/help/help/topics/Annotations/Annotations.html index 288af811a4..acb36fbbd4 100644 --- a/Ghidra/Features/Base/src/main/help/help/topics/Annotations/Annotations.html +++ b/Ghidra/Features/Base/src/main/help/help/topics/Annotations/Annotations.html @@ -22,13 +22,15 @@ hyperlink.

-

The following text shows the syntax of the URL annotation:

+

The following text shows the syntax of a sample URL annotation:


- {@url "http://www.google.com"}
+ {@url "http://www.google.com" "Search Web"}

The bold text is required for all annotations. The italicized text is required but is - specific to the annotation being used (see the table below).

+ specific to the annotation being used (see the table below). The optional rendered display text + "Search Web" will be displayed in listing. If the optional display test is omitted, the URL + will be displayed. Quotes around display text are optional.

Examples

@@ -47,10 +49,21 @@ Rendered URL Annotation Example

When the URL text (e.g., "http://www.google.com") in the above image is clicked from within - Ghidra, a web browser is launched and attempts to load the corresponding web page. If the URL text - is of the form ghidra://<host>[:<port>]/<repository-name>/<program-path>[#<address-or-symbol-ref>] - an attempt will be made to open the corresponding program from the referenced Ghidra Server (e.g., - ghidra://myserver/Repo/notepad.exe#entry).

+ Ghidra, a web browser is launched and attempts to load the corresponding web page.

+ +

If the URL text corresponds to a Ghidra URL and attempt will be made to open the referenced + Program file within the Code Browser. Such a URL may refer to a Program file from a + local project or Ghidra Server. The Ghidra URL forms supported include:

+ +

Remote Ghidra Server file
+ ghidra://<host>[:<port>]/<repository-name>/<program-path>[#<address-or-symbol-ref>]
+ Example: ghidra://hostname/Repo/notepad.exe#entry +

+ +

Local Ghidra project file
+ ghidra:/[<project-path>/]<project-name>?/<program-path>[#<address-or-symbol-ref>]
+ Example: ghidra:/share/MyProject?/notepad.exe#entry +

Valid Annotations

@@ -174,9 +187,10 @@ Displays the given URL has a hyperlink. This annotation optionally takes display text so that the hyperlink may be displayed with text other than that of the URL.

- References to ghidra://, which refer to a program within a Ghidra Server repository, - will be opened within the Listing display, while all other URL protocols (e.g., http://, https://, - file://, etc.) will be launched via an external web browser (see + References to a program file on a Ghidra Server (ghidra://<host...) or + local project (ghidra:/<project-...) will be opened within the Listing display, + while all other URL forms (e.g., http://, https://, file://, etc.) will be + launched via an external web browser (see command configuration for Processor Manuals). @@ -216,6 +230,12 @@
  • {@url "ghidra://myserver/Repo/notepad.exe#entry"}
  • {@url "ghidra://myserver/Repo/notepad.exe" "see notepad.exe"}
  • + +
  • {@url "ghidra:/share/MyProject?/notepad.exe#entry"}
  • + +
  • {@url "ghidra:/share/MyProject?/notepad.exe" "see notepad.exe"}
  • + + @@ -224,8 +244,9 @@ Program
    - Displays a hyperlink to the given Ghidra program name that - will open that program in a new Listing tab when clicked.
    + Displays a hyperlink to the given Ghidra program pathname + with the current project. Referenced program + will open in a new Listing tab when clicked.
    You may optionally provide an address or symbol to be displayed when the program is opened by appending to the program name an '@' character, followed by an address or symbol name. 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 db73da2d09..8ba831bb37 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 @@ -688,6 +688,54 @@

    The tabbed pane for read-only Project data is removed from the Project Window.

    + +

    Create Linked Folder or File

    + +
    +

    This feature allows you to create a folder or file link in your active project to a + corresponding folder or file within a read-only viewed project. + This is done using a Ghidra URL which references the + file in its local or remote storage location. If the viewed project corresponds to a + viewed repository a remote URL will be used, while other cases will refer to the + locally viewed project. It is possible for links to become broken if the referenced + repository, local project or file location are changed.

    +
      +
    1. Select a single folder or file from a viewed READ-ONLY Project Data tree.
    2. +
    3. Right mouse click on the selected tree node and choose the Copy option.
    4. +
    5. Select a destination folder in the active project tree.
    6. +
    7. Right mouse click on the folder and choose the Paste as Link option. +

      Currently, linked-file types are + currently limited to Program and Data Type Archive files + only. The Past as Link menu item will be disabled for + unsupported file content types or for other unsupported situations such as internal + linking within the same project.

      +
    8. +
    +

    A linked-file may be opened in a tool via the project window in the same fashion that + a normal file is opened (e.g., double-left-mouse-click or drag-n-drop onto a tool box icon). + Such a project file may also be opened within a Tool using its File->Open... action + and selected from the resulting project file selection dilaog. + Clicking on a linked-folder in the active project window will open that location in a + READ-ONLY Project Data tree. The user may be prompted for a shared repository + connection password when accessing a linked folder or file.

    +

    Within a project file chooser dialog a linked-folder may be expanded in a similar fashion + to local folders provided any neccessary repository connection can be completed.

    +

    Add to Version Control... is supported + for repository folder and file links only and will be disabled for links to a + local project.

    +

    Currently, linked-files only provide access + to the latest file version and do not facilitate access to older file versions.

    +

    The project window below shows a Program file-link "Program1" which is linked to the + same file in the viewed project. Hovering the mouse over a linked-file will show the URL + of the linked file or folder. The chain-link icon decoration indicates such a linked + file or folder.

    + +
    + +
    + +
    +

    Workspaces

    diff --git a/Ghidra/Features/Base/src/main/help/help/topics/FrontEndPlugin/images/LinkOtherProject.png b/Ghidra/Features/Base/src/main/help/help/topics/FrontEndPlugin/images/LinkOtherProject.png new file mode 100644 index 0000000000000000000000000000000000000000..15d4c8bcaaa7595d9a82b2bd7fb263e58ab0a7be GIT binary patch literal 37226 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 wmVafX+-xWiwa^j5l?$#TPAi^2XzT0GJNpo&W#< diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/calltree/CallTreeProvider.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/calltree/CallTreeProvider.java index 52ee26081e..af9625d203 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/calltree/CallTreeProvider.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/calltree/CallTreeProvider.java @@ -1266,19 +1266,24 @@ public class CallTreeProvider extends ComponentProviderAdapter implements Domain } @Override - protected void expandNode(GTreeNode node, TaskMonitor monitor) throws CancelledException { + protected void expandNode(GTreeNode node, boolean force, TaskMonitor monitor) + throws CancelledException { TreePath treePath = node.getTreePath(); Object[] path = treePath.getPath(); if (path.length > maxDepth) { return; } + if (!force && !node.isAutoExpandPermitted()) { + return; + } + CallNode callNode = (CallNode) node; if (callNode.functionIsInPath()) { return; // this path hit a function that is already in the path } - super.expandNode(node, monitor); + super.expandNode(node, false, monitor); } } diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/clear/ClearFlowAndRepairCmd.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/clear/ClearFlowAndRepairCmd.java index 6188600b22..b767ed8591 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/clear/ClearFlowAndRepairCmd.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/clear/ClearFlowAndRepairCmd.java @@ -106,9 +106,33 @@ public class ClearFlowAndRepairCmd extends BackgroundCommand { CodeUnit cu = cuIter.next(); if (cu instanceof Instruction) { Instruction instr = (Instruction) cu; + + // check for function on delay slot + if (listing.getFunctionAt(instr.getMinAddress()) != null) { + continue; // skip since it will be picked-up by flow if appropriate + } + + // check for fallthrough to instruction Address ffAddr = instr.getFallFrom(); if (ffAddr != null && startAddrs.contains(ffAddr)) { - continue; // skip since it will be picked-up by flow + continue; // skip since it will be picked-up by flow if appropriate + } + + // check for flow into delay slot + if (instr.isInDelaySlot()) { + boolean skip = false; + ReferenceIterator refToIter = instr.getReferenceIteratorTo(); + while (refToIter.hasNext()) { + Reference ref = refToIter.next(); + RefType refType = ref.getReferenceType(); + if (refType.isJump() || refType.isCall()) { + skip = true; + break; + } + } + if (skip) { + continue; // skip since it will be picked-up by flow if appropriate + } } } else { diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/comments/CommentsDialog.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/comments/CommentsDialog.java index 91ba90a4e2..2c335a7a52 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/comments/CommentsDialog.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/comments/CommentsDialog.java @@ -240,10 +240,11 @@ public class CommentsDialog extends DialogComponentProvider implements KeyListen //////////////////////////////////////////////////////////////////// private AnnotationAdapterWrapper[] getAnnotationAdapterWrappers() { - AnnotatedStringHandler[] annotations = Annotation.getAnnotatedStringHandlers(); - AnnotationAdapterWrapper[] retVal = new AnnotationAdapterWrapper[annotations.length]; - for (int i = 0; i < annotations.length; i++) { - retVal[i] = new AnnotationAdapterWrapper(annotations[i]); + List annotations = Annotation.getAnnotatedStringHandlers(); + int count = annotations.size(); + AnnotationAdapterWrapper[] retVal = new AnnotationAdapterWrapper[count]; + for (int i = 0; i < count; i++) { + retVal[i] = new AnnotationAdapterWrapper(annotations.get(i)); } return retVal; } diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/datamgr/DataTypeManagerPlugin.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/datamgr/DataTypeManagerPlugin.java index 2d8a5df43f..9d13288744 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/datamgr/DataTypeManagerPlugin.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/datamgr/DataTypeManagerPlugin.java @@ -56,7 +56,6 @@ import ghidra.framework.options.SaveState; import ghidra.framework.plugintool.PluginInfo; import ghidra.framework.plugintool.PluginTool; import ghidra.framework.plugintool.util.PluginStatus; -import ghidra.program.database.DataTypeArchiveContentHandler; import ghidra.program.database.data.ProgramDataTypeManager; import ghidra.program.model.address.AddressSetView; import ghidra.program.model.data.*; @@ -245,8 +244,7 @@ public class DataTypeManagerPlugin extends ProgramPlugin Project project = tool.getProjectManager().getActiveProject(); if (project != null && project.getName().equals(projectName)) { DomainFile df = project.getProjectData().getFile(pathname); - if (df != null && DataTypeArchiveContentHandler.DATA_TYPE_ARCHIVE_CONTENT_TYPE - .equals(df.getContentType())) { + if (DataTypeArchive.class.isAssignableFrom(df.getDomainObjectClass())) { return df; } } @@ -588,12 +586,9 @@ public class DataTypeManagerPlugin extends ProgramPlugin openArchive(domainFile, version); } }; - DomainFileFilter filter = f -> { - Class c = f.getDomainObjectClass(); - return DataTypeArchive.class.isAssignableFrom(c); - }; openDialog = - new OpenVersionedFileDialog(tool, "Open Project Data Type Archive", filter); + new OpenVersionedFileDialog(tool, "Open Project Data Type Archive", + df -> DataTypeArchive.class.isAssignableFrom(df.getDomainObjectClass())); openDialog.setHelpLocation(new HelpLocation(HelpTopics.PROGRAM, "Open_File_Dialog")); openDialog.addOkActionListener(listener); } diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/datamgr/DataTypesProvider.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/datamgr/DataTypesProvider.java index ff576efdc3..840271f4bb 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/datamgr/DataTypesProvider.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/datamgr/DataTypesProvider.java @@ -608,8 +608,15 @@ public class DataTypesProvider extends ComponentProviderAdapter { ArchiveNode archiveNode = dataTypeNode.getArchiveNode(); if (archiveNode instanceof ProjectArchiveNode && !archiveNode.isModifiable()) { - Msg.showInfo(getClass(), archiveGTree, "Archive Not Checked Out", - "You must checkout this archive before you may edit data types."); + ProjectArchiveNode projectArchive = (ProjectArchiveNode) archiveNode; + if (projectArchive.getDomainFile().isReadOnly()) { + Msg.showInfo(getClass(), archiveGTree, "Read-Only Archive", + "You may not edit data type within a read-only project archive."); + } + else { + Msg.showInfo(getClass(), archiveGTree, "Archive Not Checked Out", + "You must checkout this archive before you may edit data types."); + } return; } diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/datamgr/actions/associate/AssociateDataTypeAction.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/datamgr/actions/associate/AssociateDataTypeAction.java index 3e6a4f8b51..20e1a7c2b4 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/datamgr/actions/associate/AssociateDataTypeAction.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/datamgr/actions/associate/AssociateDataTypeAction.java @@ -38,6 +38,7 @@ import ghidra.app.plugin.core.datamgr.tree.ArchiveNode; import ghidra.app.plugin.core.datamgr.tree.DataTypeNode; import ghidra.app.plugin.core.datamgr.util.DataTypeTreeCopyMoveTask; import ghidra.app.plugin.core.datamgr.util.DataTypeTreeCopyMoveTask.ActionType; +import ghidra.app.plugin.core.datamgr.util.DataTypeUtils; import ghidra.program.model.data.*; import ghidra.util.Msg; import ghidra.util.layout.PairLayout; @@ -85,26 +86,21 @@ public class AssociateDataTypeAction extends DockingAction { return !nodes.isEmpty(); } - private boolean hasSingleModifiableSourceArchive(List nodes) { + private Archive getSingleDTArchive(List nodes) { - Archive sourceArchive = null; + Archive dtArchive = null; for (GTreeNode node : nodes) { Archive archive = findArchive(node); - if (sourceArchive == null) { - sourceArchive = archive; + if (dtArchive == null) { + dtArchive = archive; continue; } - if (sourceArchive != archive) { - return false; + if (dtArchive != archive) { + return null; } } - - if (sourceArchive != null && sourceArchive.isModifiable()) { - return true; - } - - return false; + return dtArchive; } private static Archive findArchive(GTreeNode node) { @@ -134,13 +130,20 @@ public class AssociateDataTypeAction extends DockingAction { List nodes = ((DataTypesActionContext) context).getSelectedNodes(); - if (!hasSingleModifiableSourceArchive(nodes)) { - Msg.showInfo(this, getProviderComponent(), "Multiple Source Archives", + Archive dtArchive = getSingleDTArchive(nodes); + if (dtArchive == null) { + Msg.showInfo(this, getProviderComponent(), "Multiple Data Type Archives", "The currently selected nodes are from multiple archives.\n" + "Please select only nodes from a single archvie."); return; } + if (!dtArchive.isModifiable()) { + DataTypeUtils.showUnmodifiableArchiveErrorMessage(context.getSourceComponent(), + "Disassociate Failed", dtArchive.getDataTypeManager()); + return; + } + if (isAlreadyAssociated((DataTypesActionContext) context)) { Msg.showInfo(this, getProviderComponent(), "Already Associated", "One or more of the currently selected nodes are already associated\n" + diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/datamgr/archive/DataTypeManagerHandler.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/datamgr/archive/DataTypeManagerHandler.java index 5a65c0eaf7..ba12a297cf 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/datamgr/archive/DataTypeManagerHandler.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/datamgr/archive/DataTypeManagerHandler.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. @@ -76,7 +76,7 @@ public class DataTypeManagerHandler { private DataTreeDialog dataTreeSaveDialog; private CreateDataTypeArchiveDataTreeDialog dataTreeCreateDialog; private boolean treeDialogCancelled = false; - private DomainFileFilter domainFileFilter; + private DomainFileFilter createArchiveFileFilter; private DataTypeIndexer dataTypeIndexer; private List archiveManagerlisteners = new ArrayList<>(); @@ -102,9 +102,17 @@ public class DataTypeManagerHandler { dataTypeIndexer.addDataTypeManager(builtInDataTypesManager); openArchives.add(new BuiltInArchive(this, builtInDataTypesManager)); - domainFileFilter = f -> { - Class c = f.getDomainObjectClass(); - return DataTypeArchive.class.isAssignableFrom(c); + createArchiveFileFilter = new DomainFileFilter() { + + @Override + public boolean accept(DomainFile df) { + return DataTypeArchive.class.isAssignableFrom(df.getDomainObjectClass()); + } + + @Override + public boolean followLinkedFolders() { + return false; + } }; folderListener = new MyFolderListener(); @@ -1425,7 +1433,7 @@ public class DataTypeManagerHandler { } }; dataTreeSaveDialog = - new DataTreeDialog(null, "Save As", DataTreeDialog.SAVE, domainFileFilter); + new DataTreeDialog(null, "Save As", DataTreeDialog.SAVE, createArchiveFileFilter); dataTreeSaveDialog.addOkActionListener(listener); dataTreeSaveDialog @@ -1465,7 +1473,7 @@ public class DataTypeManagerHandler { }; dataTreeCreateDialog = new CreateDataTypeArchiveDataTreeDialog(null, "Create", - DataTreeDialog.CREATE, domainFileFilter); + DataTreeDialog.CREATE, createArchiveFileFilter); dataTreeCreateDialog.addOkActionListener(listener); dataTreeCreateDialog.setHelpLocation( diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/datamgr/util/DataTypeUtils.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/datamgr/util/DataTypeUtils.java index f637ed089e..0bf821c53c 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/datamgr/util/DataTypeUtils.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/datamgr/util/DataTypeUtils.java @@ -24,6 +24,7 @@ import javax.swing.Icon; import generic.theme.GColor; import generic.theme.GIcon; import ghidra.app.services.DataTypeQueryService; +import ghidra.program.database.data.ProjectDataTypeManager; import ghidra.program.model.data.*; import ghidra.program.model.data.Enum; import ghidra.util.Msg; @@ -438,9 +439,18 @@ public class DataTypeUtils { msg = "The archive file is not modifiable!\nYou must open the archive for editing\n" + "before performing this operation.\n" + dtm.getName(); } + else if (dtm instanceof ProjectDataTypeManager) { + ProjectDataTypeManager projectDtm = (ProjectDataTypeManager) dtm; + if (!projectDtm.isUpdatable() && !projectDtm.getDomainFile().canCheckout()) { + msg = "The project archive is not modifiable!\n" + dtm.getName(); + } + else { + msg = "The project archive is not modifiable!\nYou must check out the archive\n" + + "before performing this operation.\n" + dtm.getName(); + } + } else { - msg = "The project archive is not modifiable!\nYou must check out the archive\n" + - "before performing this operation.\n" + dtm.getName(); + msg = "The Archive is not modifiable!\n"; } Msg.showInfo(DataTypeUtils.class, parent, title, msg); } diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/exporter/ExporterDialog.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/exporter/ExporterDialog.java index 79a56332fc..3c579e1f92 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/exporter/ExporterDialog.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/exporter/ExporterDialog.java @@ -295,11 +295,15 @@ public class ExporterDialog extends DialogComponentProvider implements AddressFa return comboBox; } + @SuppressWarnings("unchecked") private List getApplicableExporters() { List list = new ArrayList<>(ClassSearcher.getInstances(Exporter.class)); - Class domainObjectClass = domainFile.getDomainObjectClass(); - list.removeIf(exporter -> !exporter.canExportDomainObject(domainObjectClass)); - Collections.sort(list, (o1, o2) -> o1.toString().compareTo(o2.toString())); + Class domainObjectClass = domainFile.getDomainObjectClass(); + if (DomainObject.class.isAssignableFrom(domainObjectClass)) { + list.removeIf(exporter -> !exporter + .canExportDomainObject((Class) domainObjectClass)); + Collections.sort(list, (o1, o2) -> o1.toString().compareTo(o2.toString())); + } return list; } @@ -422,8 +426,16 @@ public class ExporterDialog extends DialogComponentProvider implements AddressFa private void doOpenFile(TaskMonitor monitor) { try { - domainObject = domainFile.getImmutableDomainObject(this, DomainFile.DEFAULT_VERSION, - TaskMonitor.DUMMY); + if (domainFile.isLinkFile()) { + // Linked files are always subject to upgrade if needed and do not support + // getImmutableDomainObject + domainObject = + domainFile.getReadOnlyDomainObject(this, DomainFile.DEFAULT_VERSION, monitor); + } + else { + domainObject = + domainFile.getImmutableDomainObject(this, DomainFile.DEFAULT_VERSION, monitor); + } } catch (VersionException | CancelledException | IOException e) { Msg.showError(this, getComponent(), "Error Opening File", diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/misc/MyProgramChangesDisplayPlugin.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/misc/MyProgramChangesDisplayPlugin.java index b375368cf5..4e3a9f303a 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/misc/MyProgramChangesDisplayPlugin.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/misc/MyProgramChangesDisplayPlugin.java @@ -147,7 +147,8 @@ public class MyProgramChangesDisplayPlugin extends ProgramPlugin implements Doma @Override public boolean isEnabledForContext(ActionContext context) { - return currentProgram != null && currentProgram.getDomainFile().canCheckin(); + return currentProgram != null && + currentProgram.getDomainFile().modifiedSinceCheckout(); } }; diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/processors/LanguageProviderPlugin.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/processors/LanguageProviderPlugin.java index 2dbf91aba7..c68b4c582d 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/processors/LanguageProviderPlugin.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/processors/LanguageProviderPlugin.java @@ -26,8 +26,8 @@ import docking.action.MenuData; import docking.widgets.OptionDialog; import ghidra.app.CorePluginPackage; import ghidra.app.plugin.PluginCategoryNames; -import ghidra.framework.main.FrontEndTool; import ghidra.framework.main.ApplicationLevelPlugin; +import ghidra.framework.main.FrontEndTool; import ghidra.framework.main.datatable.ProjectDataContext; import ghidra.framework.model.*; import ghidra.framework.plugintool.*; @@ -316,18 +316,20 @@ public final class LanguageProviderPlugin extends Plugin implements ApplicationL try { SwingUtilities.invokeAndWait(() -> { ToolServices toolServices = tool.getToolServices(); - String defaultToolName = toolServices.getDefaultToolTemplate(file).getName(); - for (PluginTool t : toolServices.getRunningTools()) { - if (t.getName().equals(defaultToolName)) { - openTool = t; - break; + ToolTemplate defaultToolTemplate = toolServices.getDefaultToolTemplate(file); + if (defaultToolTemplate != null) { + String defaultToolName = defaultToolTemplate.getName(); + for (PluginTool t : toolServices.getRunningTools()) { + if (t.getName().equals(defaultToolName)) { + openTool = t; + break; + } } } - if (openTool != null) { - openTool.acceptDomainFiles(new DomainFile[] { file }); - } - else { - openTool = tool.getToolServices().launchDefaultTool(file); + if (openTool == null || + !openTool.acceptDomainFiles(new DomainFile[] { file })) { + Msg.showError(this, tool.getToolFrame(), "Failed to Open Program", + "A suitable default tool could not found!"); } }); } @@ -336,7 +338,7 @@ public final class LanguageProviderPlugin extends Plugin implements ApplicationL } catch (InvocationTargetException e) { Throwable t = e.getCause(); - Msg.showError(this, tool.getToolFrame(), "Tool Launch Failed", + Msg.showError(this, tool.getToolFrame(), "Failed to Open Program", "An error occurred while attempting to launch your default tool!", t); } } diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/progmgr/MultiProgramManager.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/progmgr/MultiProgramManager.java index 1cd8236b27..cd5e593d04 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/progmgr/MultiProgramManager.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/progmgr/MultiProgramManager.java @@ -30,6 +30,7 @@ import ghidra.app.events.*; import ghidra.app.nav.Navigatable; import ghidra.app.services.*; import ghidra.app.util.task.OpenProgramTask; +import ghidra.app.util.task.OpenProgramTask.OpenProgramRequest; import ghidra.framework.data.DomainObjectAdapterDB; import ghidra.framework.model.*; import ghidra.framework.plugintool.PluginTool; @@ -42,9 +43,6 @@ import ghidra.util.task.TaskLauncher; class MultiProgramManager implements DomainObjectListener, TransactionListener { - // arbitrary counter for given ProgramInfo objects and ID to use for sorting - private static final AtomicInteger nextAvailableId = new AtomicInteger(); - private ProgramManagerPlugin plugin; private PluginTool tool; private ProgramInfo currentInfo; @@ -82,25 +80,31 @@ class MultiProgramManager implements DomainObjectListener, TransactionListener { }; } - void addProgram(Program p, URL ghidraURL, int state) { + void addProgram(Program p, DomainFile domainFile, int state) { + addProgram(new ProgramInfo(p, domainFile, state != ProgramManager.OPEN_HIDDEN), state); + } + + void addProgram(Program p, URL ghidraUrl, int state) { + addProgram(new ProgramInfo(p, ghidraUrl, state != ProgramManager.OPEN_HIDDEN), state); + } + + private void addProgram(ProgramInfo programInfo, int state) { + Program p = programInfo.program; ProgramInfo oldInfo = getInfo(p); if (oldInfo == null) { + oldInfo = programInfo; p.addConsumer(tool); - ProgramInfo info = new ProgramInfo(p, state != ProgramManager.OPEN_HIDDEN); - info.ghidraURL = ghidraURL; - openPrograms.add(info); + openPrograms.add(oldInfo); openPrograms.sort(Comparator.naturalOrder()); - programMap.put(p, info); + programMap.put(p, oldInfo); fireOpenEvents(p); p.addListener(this); p.addTransactionListener(this); } - else { - if (!oldInfo.visible && state != ProgramManager.OPEN_HIDDEN) { - oldInfo.setVisible(true); - } + else if (!oldInfo.visible && state != ProgramManager.OPEN_HIDDEN) { + oldInfo.setVisible(true); } if (state == ProgramManager.OPEN_CURRENT) { saveLocation(); @@ -246,12 +250,7 @@ class MultiProgramManager implements DomainObjectListener, TransactionListener { TransientToolState toolState = null; if (currentInfo != null) { currentInfo.setVisible(true); - DomainFile df = currentInfo.program.getDomainFile(); - String title = df.toString(); - if (df.isReadOnly()) { - title = title + " [Read-Only]"; - } - tool.setSubTitle(title); + tool.setSubTitle(currentInfo.toString()); txMonitor.setProgram(currentInfo.program); if (currentInfo.lastState != null) { toolState = currentInfo.lastState; @@ -370,7 +369,7 @@ class MultiProgramManager implements DomainObjectListener, TransactionListener { return (info != null && info.owner != null); } - private ProgramInfo getInfo(Program p) { + ProgramInfo getInfo(Program p) { if (p == null) { return null; } @@ -378,9 +377,6 @@ class MultiProgramManager implements DomainObjectListener, TransactionListener { } Program getOpenProgram(URL ghidraURL) { - if (!GhidraURL.isServerRepositoryURL(ghidraURL)) { - return null; - } URL normalizedURL = GhidraURL.getNormalizedURL(ghidraURL); for (ProgramInfo info : programMap.values()) { URL url = info.ghidraURL; @@ -392,10 +388,10 @@ class MultiProgramManager implements DomainObjectListener, TransactionListener { } Program getOpenProgram(DomainFile domainFile, int version) { - for (Program program : programMap.keySet()) { - DomainFile programDomainFile = program.getDomainFile(); - if (filesMatch(domainFile, version, programDomainFile)) { - return program; + for (ProgramInfo info : programMap.values()) { + DomainFile df = info.domainFile; + if (df != null && filesMatch(domainFile, version, df)) { + return info.program; } } return null; @@ -413,7 +409,7 @@ class MultiProgramManager implements DomainObjectListener, TransactionListener { if (!SystemUtilities.isEqual(file1.getProjectLocator(), file2.getProjectLocator())) { return false; } - + // TODO: version check is questionable - unclear how proxy file would work int openVersion = file2.isReadOnly() ? file2.getVersion() : -1; return version == openVersion; } @@ -488,28 +484,54 @@ class MultiProgramManager implements DomainObjectListener, TransactionListener { OpenProgramTask openTask = new OpenProgramTask(file, -1, this); openTask.setSilent(); new TaskLauncher(openTask, tool.getToolFrame()); - Program openProgram = openTask.getOpenProgram(); - plugin.openProgram(openProgram, - dataState != null ? ProgramManager.OPEN_CURRENT : ProgramManager.OPEN_VISIBLE); - openProgram.release(this); - removeProgram((Program) oldObject); - if (dataState != null) { - tool.restoreDataStateFromXml(dataState); + OpenProgramRequest openProgramReq = openTask.getOpenProgram(); + if (openProgramReq != null) { + plugin.openProgram(openProgramReq.getProgram(), + dataState != null ? ProgramManager.OPEN_CURRENT : ProgramManager.OPEN_VISIBLE); + openProgramReq.release(); + removeProgram((Program) oldObject); + if (dataState != null) { + tool.restoreDataStateFromXml(dataState); + } } } } - private class ProgramInfo implements Comparable { + class ProgramInfo implements Comparable { + + // arbitrary counter for given ProgramInfo objects and ID to use for sorting + private static final AtomicInteger nextAvailableId = new AtomicInteger(); + + public final Program program; + + // NOTE: domainFile and ghidraURL use are mutually exclusive and reflect how program was + // opened. Supported cases include: + // 1. Opened via Program file + // 2. Opened via ProgramLink file + // 3. Opened via Program URL + + public final DomainFile domainFile; // may be link file + public final URL ghidraURL; - private Program program; - private URL ghidraURL; private TransientToolState lastState; private int instance; - private boolean visible; + private boolean visible = false; private Object owner; - ProgramInfo(Program p, boolean visible) { + private String str; // cached toString + + ProgramInfo(Program p, DomainFile domainFile, boolean visible) { this.program = p; + this.domainFile = domainFile; + this.ghidraURL = null; + this.visible = visible; + instance = nextAvailableId.incrementAndGet(); + } + + ProgramInfo(Program p, URL ghidraURL, boolean visible) { + this.program = p; + this.domainFile = null; + this.ghidraURL = ghidraURL; this.visible = visible; instance = nextAvailableId.incrementAndGet(); } @@ -523,5 +545,24 @@ class MultiProgramManager implements DomainObjectListener, TransactionListener { public int compareTo(ProgramInfo info) { return instance - info.instance; } + + @Override + public String toString() { + if (str != null) { + return str; + } + StringBuilder buf = new StringBuilder(); + DomainFile df = program.getDomainFile(); + if (domainFile != null && domainFile.isLinkFile()) { + buf.append(domainFile.getName()); + buf.append("->"); + } + buf.append(df.toString()); + if (df.isReadOnly()) { + buf.append(" [Read-Only]"); + } + str = buf.toString(); + return str; + } } } diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/progmgr/ProgramManagerPlugin.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/progmgr/ProgramManagerPlugin.java index 6fc4d4aac8..454ecd2fd9 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/progmgr/ProgramManagerPlugin.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/progmgr/ProgramManagerPlugin.java @@ -18,7 +18,6 @@ package ghidra.app.plugin.core.progmgr; import java.awt.Component; import java.awt.event.ActionListener; import java.beans.PropertyEditor; -import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; import java.util.ArrayList; @@ -32,26 +31,26 @@ import ghidra.app.CorePluginPackage; import ghidra.app.context.ProgramActionContext; import ghidra.app.events.*; import ghidra.app.plugin.PluginCategoryNames; +import ghidra.app.plugin.core.progmgr.MultiProgramManager.ProgramInfo; import ghidra.app.services.ProgramManager; import ghidra.app.util.HelpTopics; import ghidra.app.util.NamespaceUtils; import ghidra.app.util.task.OpenProgramTask; +import ghidra.app.util.task.OpenProgramTask.OpenProgramRequest; import ghidra.framework.client.ClientUtil; -import ghidra.framework.data.ProjectFileManager; import ghidra.framework.main.OpenVersionedFileDialog; import ghidra.framework.model.*; import ghidra.framework.options.*; import ghidra.framework.plugintool.*; import ghidra.framework.plugintool.util.PluginStatus; -import ghidra.framework.protocol.ghidra.*; -import ghidra.program.database.ProgramContentHandler; +import ghidra.framework.protocol.ghidra.GhidraURL; import ghidra.program.model.address.*; import ghidra.program.model.listing.Program; import ghidra.program.model.symbol.Symbol; import ghidra.program.model.symbol.SymbolType; import ghidra.program.util.*; import ghidra.util.*; -import ghidra.util.exception.NotFoundException; +import ghidra.util.exception.AssertException; import ghidra.util.task.TaskLauncher; //@formatter:off @@ -121,16 +120,21 @@ public class ProgramManagerPlugin extends Plugin implements ProgramManager { if (domainFile == null) { continue; } - if (!(Program.class.isAssignableFrom(domainFile.getDomainObjectClass()))) { - continue; + Class domainObjectClass = domainFile.getDomainObjectClass(); + if (Program.class.isAssignableFrom(domainObjectClass)) { + filesToOpen.add(domainFile); } - filesToOpen.add(domainFile); } openPrograms(filesToOpen); return !filesToOpen.isEmpty(); } + @Override + public boolean accept(URL url) { + return openProgram(url, OPEN_CURRENT) != null; + } + @Override public Class[] getSupportedDataTypes() { return new Class[] { Program.class }; @@ -144,89 +148,53 @@ public class ProgramManagerPlugin extends Plugin implements ProgramManager { return null; } - return Swing.runNow(() -> doOpenProgram(ghidraURL, state)); - } - - private void messageBadProgramURL(URL ghidraURL) { - Msg.showError(this, null, "Invalid Ghidra URL", - "Ghidra URL does not reference a Ghidra Program: " + ghidraURL); - } - - protected Program doOpenProgram(URL ghidraURL, int openState) { - if (!GhidraURL.isServerRepositoryURL(ghidraURL)) { - Msg.showError(this, null, "Invalid Ghidra URL", - "Ghidra URL does not reference a Ghidra Program: " + ghidraURL); - return null; - } - Program openProgram = programMgr.getOpenProgram(ghidraURL); - if (openProgram != null) { - programMgr.addProgram(openProgram, GhidraURL.getNormalizedURL(ghidraURL), openState); - if (openState == ProgramManager.OPEN_CURRENT) { - gotoProgramRef(openProgram, ghidraURL.getRef()); + // Check for URL already open and re-use + URL url = GhidraURL.getNormalizedURL(ghidraURL); + Program p = programMgr.getOpenProgram(url); + if (p != null) { + showProgram(p, url, state); + if (state == ProgramManager.OPEN_CURRENT) { + gotoProgramRef(p, ghidraURL.getRef()); programMgr.saveLocation(); } - contextChanged(); - return openProgram; + return p; } - GhidraURLWrappedContent wrappedContent = null; - Object content = null; + Program program = Swing.runNow(() -> doOpenProgram(ghidraURL, state)); + + if (program != null) { + Msg.info(this, "Opened program in " + tool.getName() + " tool: " + ghidraURL); + } + return program; + } + + /** + * Open GhidraURL which corresponds to {@code ghidra://} remote URLs which correspond to a + * repository program file. + * @param ghidraURL Ghidra URL which specified Program to be opened which optional ref + * @param openState open state + * @return program instance of null if open failed + */ + private Program doOpenProgram(URL ghidraURL, int openState) { + Program p = null; try { - GhidraURLConnection c = (GhidraURLConnection) ghidraURL.openConnection(); - Object obj = c.getContent(); - if (c.getResponseCode() == GhidraURLConnection.GHIDRA_UNAUTHORIZED) { - return null; // assume user already notified + URL url = GhidraURL.getNormalizedURL(ghidraURL); + OpenProgramTask task = new OpenProgramTask(url, this); + new TaskLauncher(task, tool.getToolFrame()); + OpenProgramRequest openProgramReq = task.getOpenProgram(); + if (openProgramReq != null) { + p = openProgramReq.getProgram(); + showProgram(p, url, openState); + openProgramReq.release(); } - if (!(obj instanceof GhidraURLWrappedContent)) { - messageBadProgramURL(ghidraURL); - return null; - } - wrappedContent = (GhidraURLWrappedContent) obj; - content = wrappedContent.getContent(this); - if (!(content instanceof DomainFile)) { - messageBadProgramURL(ghidraURL); - return null; - } - DomainFile df = (DomainFile) content; - if (!ProgramContentHandler.PROGRAM_CONTENT_TYPE.equals(df.getContentType())) { - messageBadProgramURL(ghidraURL); - return null; - } - - OpenProgramTask task = new OpenProgramTask(df, true, this); - TaskLauncher.launch(task); - - openProgram = task.getOpenProgram(); - if (openProgram == null) { - return null; - } - - programMgr.addProgram(openProgram, GhidraURL.getNormalizedURL(ghidraURL), openState); - contextChanged(); - openProgram.release(this); - if (openState == ProgramManager.OPEN_CURRENT) { - gotoProgramRef(openProgram, ghidraURL.getRef()); - programMgr.saveLocation(); - } - return openProgram; - } - catch (NotFoundException e) { - messageBadProgramURL(ghidraURL); - } - catch (MalformedURLException e) { - Msg.showError(this, null, "Invalid Ghidra URL", - "Improperly formed Ghidra URL: " + ghidraURL); - } - catch (IOException e) { - Msg.showError(this, null, "Program Open Failed", - "Failed to open Ghidra URL: " + e.getMessage()); } finally { - if (content != null) { - wrappedContent.release(content, this); + if (p != null && openState == ProgramManager.OPEN_CURRENT) { + gotoProgramRef(p, ghidraURL.getRef()); + programMgr.saveLocation(); } } - return null; + return p; } private boolean gotoProgramRef(Program program, String ref) { @@ -296,9 +264,7 @@ public class ProgramManagerPlugin extends Plugin implements ProgramManager { } Program program = Swing.runNow(() -> { - Program p = doOpenProgram(domainFile, version, state); - contextChanged(); - return p; + return doOpenProgram(domainFile, version, state); }); if (program != null) { @@ -460,13 +426,39 @@ public class ProgramManagerPlugin extends Plugin implements ProgramManager { @Override public void openProgram(final Program program, final int state) { + showProgram(program, program.getDomainFile(), state); + } + + private void showProgram(Program p, URL ghidraUrl, final int state) { + if (p == null || p.isClosed()) { + throw new AssertException("Opened program required"); + } if (locked) { throw new IllegalStateException( "Progam manager is locked and cannot accept a new program"); } Runnable r = () -> { - programMgr.addProgram(program, null, state); + programMgr.addProgram(p, ghidraUrl, state); + if (state == ProgramManager.OPEN_CURRENT) { + programMgr.saveLocation(); + } + contextChanged(); + }; + Swing.runNow(r); + } + + private void showProgram(Program p, DomainFile domainFile, final int state) { + if (p == null || p.isClosed()) { + throw new AssertException("Opened program required"); + } + if (locked) { + throw new IllegalStateException( + "Progam manager is locked and cannot accept a new program"); + } + + Runnable r = () -> { + programMgr.addProgram(p, domainFile, state); if (state == ProgramManager.OPEN_CURRENT) { programMgr.saveLocation(); } @@ -625,11 +617,9 @@ public class ProgramManagerPlugin extends Plugin implements ProgramManager { doOpenProgram(domainFile, version, OPEN_CURRENT); } }; - DomainFileFilter filter = f -> { - Class c = f.getDomainObjectClass(); - return Program.class.isAssignableFrom(c); - }; - openDialog = new OpenVersionedFileDialog(tool, "Open Program", filter); + openDialog = new OpenVersionedFileDialog(tool, "Open Program", f -> { + return Program.class.isAssignableFrom(f.getDomainObjectClass()); + }); openDialog.setHelpLocation(new HelpLocation(HelpTopics.PROGRAM, "Open_File_Dialog")); openDialog.addOkActionListener(listener); } @@ -638,9 +628,12 @@ public class ProgramManagerPlugin extends Plugin implements ProgramManager { } public void openPrograms(List filesToOpen) { + Program showIfNeeded = null; OpenProgramTask openTask = null; for (DomainFile domainFile : filesToOpen) { - if (programMgr.getOpenProgram(domainFile, -1) != null) { + Program p = programMgr.getOpenProgram(domainFile, -1); + if (p != null) { + showIfNeeded = p; continue; } if (openTask == null) { @@ -652,32 +645,37 @@ public class ProgramManagerPlugin extends Plugin implements ProgramManager { } if (openTask != null) { new TaskLauncher(openTask, tool.getToolFrame()); - List openPrograms = openTask.getOpenPrograms(); - - for (Program program : openPrograms) { - openProgram(program, OPEN_VISIBLE); - program.release(this); - } - if (!openPrograms.isEmpty()) { - openProgram(openPrograms.get(0), OPEN_CURRENT); + List openProgramReqs = openTask.getOpenPrograms(); + boolean isFirst = true; + for (OpenProgramRequest programReq : openProgramReqs) { + showProgram(programReq.getProgram(), programReq.getDomainFile(), + isFirst ? OPEN_CURRENT : OPEN_VISIBLE); + programReq.release(); + isFirst = false; + showIfNeeded = null; } } + if (showIfNeeded != null) { + showProgram(showIfNeeded, showIfNeeded.getDomainFile(), OPEN_CURRENT); + } } protected Program doOpenProgram(DomainFile domainFile, int version, int openState) { - Program openProgram = programMgr.getOpenProgram(domainFile, version); - if (openProgram != null) { - openProgram(openProgram, openState); - return openProgram; + Program p = programMgr.getOpenProgram(domainFile, version); + if (p != null) { + openProgram(p, openState); } - OpenProgramTask task = new OpenProgramTask(domainFile, version, this); - new TaskLauncher(task, tool.getToolFrame()); - openProgram = task.getOpenProgram(); - if (openProgram != null) { - openProgram(openProgram, openState); - openProgram.release(this); + else { + OpenProgramTask task = new OpenProgramTask(domainFile, version, this); + new TaskLauncher(task, tool.getToolFrame()); + OpenProgramRequest programReq = task.getOpenProgram(); + if (programReq != null) { + p = programReq.getProgram(); + showProgram(p, programReq.getDomainFile(), openState); + programReq.release(); + } } - return openProgram; + return p; } @Override @@ -705,18 +703,20 @@ public class ProgramManagerPlugin extends Plugin implements ProgramManager { */ @Override public void writeDataState(SaveState saveState) { - // Only remember programs from non-transient projects - ArrayList programs = new ArrayList<>(); + + ArrayList programInfos = new ArrayList<>(); for (Program p : programMgr.getAllPrograms()) { - ProjectLocator projectLocator = p.getDomainFile().getProjectLocator(); - if (projectLocator != null && !projectLocator.isTransient()) { - programs.add(p); + ProgramInfo info = programMgr.getInfo(p); + if (info != null) { + programInfos.add(info); } } - saveState.putInt("NUM_PROGRAMS", programs.size()); + + saveState.putInt("NUM_PROGRAMS", programInfos.size()); + int i = 0; - for (Program p : programs) { - writeProgramInfo(p, saveState, i++); + for (ProgramInfo programInfo : programInfos) { + writeProgramInfo(programInfo, saveState, i++); } Program p = programMgr.getCurrentProgram(); if (p != null) { @@ -768,13 +768,21 @@ public class ProgramManagerPlugin extends Plugin implements ProgramManager { } } - private void writeProgramInfo(Program program, SaveState saveState, int index) { + private void writeProgramInfo(ProgramInfo programInfo, SaveState saveState, int index) { if (locked) { return; // do not save state when locked. } + + if (programInfo.ghidraURL != null) { + saveState.putString("URL_" + index, programInfo.ghidraURL.toString()); + return; + } + String projectLocation = null; String projectName = null; String path = null; + + Program program = programInfo.program; DomainFile df = program.getDomainFile(); ProjectLocator projectLocator = df.getProjectLocator(); if (projectLocator != null && !projectLocator.isTransient()) { @@ -797,28 +805,30 @@ public class ProgramManagerPlugin extends Plugin implements ProgramManager { * Read in my data state. */ private void loadPrograms(SaveState saveState) { + int n = saveState.getInt("NUM_PROGRAMS", 0); if (n == 0) { return; } - OpenProgramTask openTask = null; + OpenProgramTask openTask = new OpenProgramTask(this); for (int index = 0; index < n; index++) { + + URL url = getGhidraURL(saveState, index); + if (url != null) { + openTask.addProgramToOpen(url); + continue; + } + DomainFile domainFile = getDomainFile(saveState, index); if (domainFile == null) { continue; } int version = getVersion(saveState, index); - - if (openTask == null) { - openTask = new OpenProgramTask(domainFile, version, this); - } - else { - openTask.addProgramToOpen(domainFile, version); - } + openTask.addProgramToOpen(domainFile, version); } - if (openTask == null) { + if (!openTask.hasOpenProgramRequests()) { return; } @@ -835,10 +845,29 @@ public class ProgramManagerPlugin extends Plugin implements ProgramManager { "Can't open program", e); } - List openPrograms = openTask.getOpenPrograms(); - for (Program program : openPrograms) { - openProgram(program, OPEN_VISIBLE); - program.release(this); + List openProgramReqs = openTask.getOpenPrograms(); + for (OpenProgramRequest programReq : openProgramReqs) { + DomainFile df = programReq.getDomainFile(); + if (df != null) { + showProgram(programReq.getProgram(), df, OPEN_VISIBLE); + } + else { + showProgram(programReq.getProgram(), programReq.getGhidraURL(), OPEN_VISIBLE); + } + programReq.release(); + } + } + + private URL getGhidraURL(SaveState saveState, int index) { + String url = saveState.getString("URL_" + index, null); + if (url == null) { + return null; + } + try { + return new URL(url); + } + catch (MalformedURLException e) { + return null; } } @@ -853,20 +882,7 @@ public class ProgramManagerPlugin extends Plugin implements ProgramManager { ProjectData projectData = tool.getProject().getProjectData(projectLocator); if (projectData == null) { - // Viewed project not available - try { - projectData = new ProjectFileManager(projectLocator, false, false); - } - catch (NotOwnerException e) { - Msg.showError(this, tool.getToolFrame(), "Program Open Failed", - "Not project owner: " + projectLocator + "(" + pathname + ")"); - return null; - } - catch (IOException e) { - Msg.showError(this, tool.getToolFrame(), "Program Open Failed", - "Project error: " + e.getMessage()); - return null; - } + return null; } DomainFile df = projectData.getFile(pathname); diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/progmgr/ProgramSaveManager.java b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/progmgr/ProgramSaveManager.java index f1a6e1a98d..6301568dd2 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/progmgr/ProgramSaveManager.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/plugin/core/progmgr/ProgramSaveManager.java @@ -45,9 +45,17 @@ class ProgramSaveManager { ProgramSaveManager(PluginTool tool, ProgramManager programMgr) { this.tool = tool; this.programMgr = programMgr; - domainFileFilter = f -> { - Class c = f.getDomainObjectClass(); - return Program.class.isAssignableFrom(c); + domainFileFilter = new DomainFileFilter() { + + @Override + public boolean accept(DomainFile df) { + return Program.class.isAssignableFrom(df.getDomainObjectClass()); + } + + @Override + public boolean followLinkedFolders() { + return false; // can't save to linked-folder (read-only) + } }; } @@ -244,7 +252,8 @@ class ProgramSaveManager { return; } if (existingFile != null) { - String msg = "Program " + name + " already exists.\n" + "Do you want to overwrite it?"; + String msg = existingFile.getContentType() + " file " + name + " already exists.\n" + + "Do you want to overwrite it?"; if (OptionDialog.showOptionDialog(tool.getToolFrame(), "Duplicate Name", msg, "Overwrite", OptionDialog.QUESTION_MESSAGE) == OptionDialog.CANCEL_OPTION) { return; diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/util/headless/HeadlessAnalyzer.java b/Ghidra/Features/Base/src/main/java/ghidra/app/util/headless/HeadlessAnalyzer.java index df63992d9a..88a9d243b0 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/util/headless/HeadlessAnalyzer.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/util/headless/HeadlessAnalyzer.java @@ -312,7 +312,7 @@ public class HeadlessAnalyzer { Object obj = c.getContent(); if (!(obj instanceof GhidraURLWrappedContent)) { throw new IOException( - "Connect to repository folder failed. Response code: " + c.getResponseCode()); + "Connect to repository folder failed. Response code: " + c.getStatusCode()); } GhidraURLWrappedContent wrappedContent = (GhidraURLWrappedContent) obj; Object content = null; @@ -336,7 +336,7 @@ public class HeadlessAnalyzer { processWithImport(folder.getPathname(), filesToImport); } } - catch (NotFoundException e) { + catch (FileNotFoundException e) { throw new IOException("Connect to repository folder failed"); } finally { @@ -369,7 +369,8 @@ public class HeadlessAnalyzer { * @param rootFolderPath root folder for imports * @param filesToImport directories and files to be imported (null or empty is acceptable if * we are in -process mode) - * @throws IOException if there was an IO-related problem + * @throws IOException if there was an IO-related problem. If caused by a failure to obtain a + * write-lock on the project the exception cause will a {@code LockException}. */ public void processLocal(String projectLocation, String projectName, String rootFolderPath, List filesToImport) throws IOException { @@ -1107,6 +1108,8 @@ public class HeadlessAnalyzer { return; } + // Do not follow folder-links or consider program links. Using content type + // to filter is best way to control this. if (!ProgramContentHandler.PROGRAM_CONTENT_TYPE.equals(domFile.getContentType())) { return; // skip non-Program files } @@ -1275,6 +1278,8 @@ public class HeadlessAnalyzer { for (DomainFile domFile : parentFolder.getFiles()) { if (filenamePattern == null || filenamePattern.matcher(domFile.getName()).matches()) { + // Do not follow folder-links or consider program links. Using content type + // to filter is best way to control this. if (ProgramContentHandler.PROGRAM_CONTENT_TYPE.equals(domFile.getContentType())) { filesProcessed = true; processFileNoImport(domFile); @@ -1308,6 +1313,8 @@ public class HeadlessAnalyzer { boolean filesProcessed = false; DomainFile domFile = parentFolder.getFile(filename); + // Do not follow folder-links or consider program links. Using content type + // to filter is best way to control this. if (domFile != null && ProgramContentHandler.PROGRAM_CONTENT_TYPE.equals(domFile.getContentType())) { filesProcessed = true; diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/util/task/OpenProgramTask.java b/Ghidra/Features/Base/src/main/java/ghidra/app/util/task/OpenProgramTask.java index 7f2f55a5b7..53ad559271 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/util/task/OpenProgramTask.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/util/task/OpenProgramTask.java @@ -16,8 +16,9 @@ package ghidra.app.util.task; import java.io.IOException; -import java.util.ArrayList; -import java.util.List; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.*; import docking.widgets.OptionDialog; import ghidra.app.util.dialog.CheckoutDialog; @@ -25,8 +26,11 @@ import ghidra.framework.client.ClientUtil; import ghidra.framework.client.RepositoryAdapter; import ghidra.framework.main.AppInfo; import ghidra.framework.model.DomainFile; +import ghidra.framework.protocol.ghidra.*; +import ghidra.framework.protocol.ghidra.GhidraURLConnection.StatusCode; import ghidra.framework.remote.User; import ghidra.framework.store.ExclusiveCheckoutException; +import ghidra.program.database.ProgramLinkContentHandler; import ghidra.program.model.lang.LanguageNotFoundException; import ghidra.program.model.listing.Program; import ghidra.util.*; @@ -37,8 +41,8 @@ import ghidra.util.task.TaskMonitor; public class OpenProgramTask extends Task { - private final List domainFileInfoList = new ArrayList<>(); - private List programList = new ArrayList<>(); + private final List openProgramRequests = new ArrayList<>(); + private List openedProgramList = new ArrayList<>(); private final Object consumer; private boolean silent; // if true operation does not permit interaction @@ -46,11 +50,16 @@ public class OpenProgramTask extends Task { private String openPromptText = "Open"; + public OpenProgramTask(Object consumer) { + super("Open Program(s)", true, false, true); + this.consumer = consumer; + } + public OpenProgramTask(DomainFile domainFile, int version, boolean forceReadOnly, Object consumer) { super("Open Program(s)", true, false, true); this.consumer = consumer; - domainFileInfoList.add(new DomainFileInfo(domainFile, version, forceReadOnly)); + openProgramRequests.add(new OpenProgramRequest(domainFile, version, forceReadOnly)); } public OpenProgramTask(DomainFile domainFile, int version, Object consumer) { @@ -65,17 +74,10 @@ public class OpenProgramTask extends Task { this(domainFile, DomainFile.DEFAULT_VERSION, false, consumer); } - public OpenProgramTask(List domainFileList, boolean forceReadOnly, - Object consumer) { - super("Open Program(s)", true, domainFileList.size() > 1, true); + public OpenProgramTask(URL ghidraURL, Object consumer) { + super("Open Program(s)", true, false, true); this.consumer = consumer; - for (DomainFile domainFile : domainFileList) { - domainFileInfoList.add(new DomainFileInfo(domainFile, -1, forceReadOnly)); - } - } - - public OpenProgramTask(List domainFileList, Object consumer) { - this(domainFileList, false, consumer); + openProgramRequests.add(new OpenProgramRequest(ghidraURL)); } public void setOpenPromptText(String text) { @@ -88,7 +90,16 @@ public class OpenProgramTask extends Task { public void addProgramToOpen(DomainFile domainFile, int version, boolean forceReadOnly) { setHasProgress(true); - domainFileInfoList.add(new DomainFileInfo(domainFile, version, forceReadOnly)); + openProgramRequests.add(new OpenProgramRequest(domainFile, version, forceReadOnly)); + } + + public void addProgramToOpen(URL ghidraURL) { + setHasProgress(true); + openProgramRequests.add(new OpenProgramRequest(ghidraURL)); + } + + public boolean hasOpenProgramRequests() { + return !openProgramRequests.isEmpty(); } /** @@ -110,100 +121,97 @@ public class OpenProgramTask extends Task { this.noCheckout = true; } - public List getOpenPrograms() { - return programList; + /** + * Get all successful open program requests + * @return all successful open program requests + */ + public List getOpenPrograms() { + return Collections.unmodifiableList(openedProgramList); } - public Program getOpenProgram() { - if (programList.isEmpty()) { + /** + * Get the first successful open program request + * @return first successful open program request or null if none + */ + public OpenProgramRequest getOpenProgram() { + if (openedProgramList.isEmpty()) { return null; } - return programList.get(0); + return openedProgramList.get(0); } @Override public void run(TaskMonitor monitor) { - taskMonitor.initialize(domainFileInfoList.size()); + taskMonitor.initialize(openProgramRequests.size()); - for (DomainFileInfo domainFileInfo : domainFileInfoList) { + for (OpenProgramRequest domainFileInfo : openProgramRequests) { if (taskMonitor.isCancelled()) { return; } - openDomainFile(domainFileInfo); - + domainFileInfo.open(); taskMonitor.incrementProgress(1); } } - private void openDomainFile(DomainFileInfo domainFileInfo) { - int version = domainFileInfo.getVersion(); - DomainFile domainFile = domainFileInfo.getDomainFile(); - if (version != DomainFile.DEFAULT_VERSION) { - openVersionedFile(domainFile, version); - } - else if (domainFileInfo.isReadOnly()) { - openReadOnlyFile(domainFile, version); - } - else { - openUnversionedFile(domainFile); - } - } - - private void openReadOnlyFile(DomainFile domainFile, int version) { + private Object openReadOnlyFile(DomainFile domainFile, URL url, int version) { taskMonitor.setMessage("Opening " + domainFile.getName()); - openReadOnly(domainFile, version); + return openReadOnly(domainFile, url, version); } - private void openVersionedFile(DomainFile domainFile, int version) { + private Object openVersionedFile(DomainFile domainFile, URL url, int version) { taskMonitor.setMessage("Getting Version " + version + " for " + domainFile.getName()); - openReadOnly(domainFile, version); + return openReadOnly(domainFile, url, version); } - private void openReadOnly(DomainFile domainFile, int version) { - String contentType = null; + private Object openReadOnly(DomainFile domainFile, URL url, int version) { + String contentType = domainFile.getContentType(); + String path = url != null ? url.toString() : domainFile.getPathname(); + Object obj = null; try { - contentType = domainFile.getContentType(); - Program program = - (Program) domainFile.getReadOnlyDomainObject(consumer, version, taskMonitor); - if (program == null) { - String errorMessage = "Can't open program - \"" + domainFile.getPathname() + "\""; + obj = domainFile.getReadOnlyDomainObject(consumer, version, taskMonitor); + + if (obj == null) { + String errorMessage = "Can't open " + contentType + " - \"" + path + "\""; if (version != DomainFile.DEFAULT_VERSION) { errorMessage += " version " + version; } - Msg.showError(this, null, "DomainFile Not Found", errorMessage); - } - else { - programList.add(program); + Msg.showError(this, null, "File Not Found", errorMessage); } + } catch (CancelledException e) { // we don't care, the task has been cancelled } catch (IOException e) { - if (domainFile.isInWritableProject()) { + if (url == null && domainFile.isInWritableProject()) { ClientUtil.handleException(AppInfo.getActiveProject().getRepository(), e, - "Get Versioned Object", null); + "Get " + contentType, null); + } + else if (version != DomainFile.DEFAULT_VERSION) { + Msg.showError(this, null, "Error Getting Versioned Program", + "Could not get version " + version + " for " + path, e); } else { - Msg.showError(this, null, "Error Getting Versioned Object", - "Could not get version " + version + " for " + domainFile.getName(), e); + Msg.showError(this, null, "Error Getting Program", + "Open program failed for " + path, e); } } catch (VersionException e) { VersionExceptionHandler.showVersionError(null, domainFile.getName(), contentType, "Open", e); } + return obj; } - private void openUnversionedFile(DomainFile domainFile) { + private Program openUnversionedFile(DomainFile domainFile) { String filename = domainFile.getName(); taskMonitor.setMessage("Opening " + filename); performOptionalCheckout(domainFile); try { - openFileMaybeUgrade(domainFile); + return openFileMaybeUgrade(domainFile); } catch (VersionException e) { String contentType = domainFile.getContentType(); @@ -226,9 +234,10 @@ public class OpenProgramTask extends Task { "Getting domain object failed.\n" + e.getMessage(), e); } } + return null; } - private void openFileMaybeUgrade(DomainFile domainFile) + private Program openFileMaybeUgrade(DomainFile domainFile) throws IOException, CancelledException, VersionException { boolean recoverFile = false; @@ -236,24 +245,18 @@ public class OpenProgramTask extends Task { recoverFile = askRecoverFile(domainFile.getName()); } + Program program = null; try { - Program program = + program = (Program) domainFile.getDomainObject(consumer, false, recoverFile, taskMonitor); - - if (program != null) { - programList.add(program); - } - } catch (VersionException e) { if (VersionExceptionHandler.isUpgradeOK(null, domainFile, openPromptText, e)) { - Program program = + program = (Program) domainFile.getDomainObject(consumer, true, recoverFile, taskMonitor); - if (program != null) { - programList.add(program); - } } } + return program; } private boolean askRecoverFile(final String filename) { @@ -294,32 +297,158 @@ public class OpenProgramTask extends Task { } } - static class DomainFileInfo { - private final DomainFile domainFile; - private final int version; - private boolean forceReadOnly; + public class OpenProgramRequest { - public DomainFileInfo(DomainFile domainFile, int version, boolean forceReadOnly) { + // ghidraURL and domainFile use are mutually exclusive + private final URL ghidraURL; + private final DomainFile domainFile; + + private URL linkURL; // link URL read from domainFile + + private final int version; + private final boolean forceReadOnly; + private Program program; + + public OpenProgramRequest(URL ghidraURL) { + if (!GhidraURL.PROTOCOL.equals(ghidraURL.getProtocol())) { + throw new IllegalArgumentException( + "unsupported protocol: " + ghidraURL.getProtocol()); + } + this.ghidraURL = ghidraURL; + this.domainFile = null; + this.version = -1; + this.forceReadOnly = true; + } + + public OpenProgramRequest(DomainFile domainFile, int version, boolean forceReadOnly) { this.domainFile = domainFile; + this.ghidraURL = null; this.version = (domainFile.isReadOnly() && domainFile.isVersioned()) ? domainFile.getVersion() : version; this.forceReadOnly = forceReadOnly; } - public boolean isReadOnly() { - return forceReadOnly || domainFile.isReadOnly() || - version != DomainFile.DEFAULT_VERSION; - } - + /** + * Get the {@link DomainFile} which corresponds to program open request. This will be + * null for all URL-based open requests. + * @return {@link DomainFile} which corresponds to program open request or null. + */ public DomainFile getDomainFile() { return domainFile; } - public int getVersion() { - return version; + /** + * Get the {@link URL} which corresponds to program open request. This will be + * null for all non-URL-based open requests. URL will be a {@link GhidraURL}. + * @return {@link URL} which corresponds to program open request or null. + */ + public URL getGhidraURL() { + return ghidraURL; } + /** + * Get the {@link URL} which corresponds to the link domainFile used to open a program. + * @return {@link URL} which corresponds to the link domainFile used to open a program. + */ + public URL getLinkURL() { + return linkURL; + } + + /** + * Get the open Program instance which corresponds to this open request. + * @return program instance or null if never opened. + */ + public Program getProgram() { + return program; + } + + /** + * Release opened program. This must be done once, and only once, on a successful + * open request. If handing ownership off to another consumer, they should be added + * as a program consumer prior to invoking this method. Releasing the last consumer + * will close the program instance. + */ + public void release() { + if (program != null) { + program.release(consumer); + } + } + + private Program openProgram(DomainFile df, URL url) { + if (version != DomainFile.DEFAULT_VERSION) { + return (Program) openVersionedFile(df, url, version); + } + if (forceReadOnly) { + return (Program) openReadOnlyFile(df, url, version); + } + return openUnversionedFile(df); + } + + void open() { + DomainFile df = domainFile; + URL url = ghidraURL; + GhidraURLWrappedContent wrappedContent = null; + Object content = null; + try { + if (df == null && url != null) { + GhidraURLConnection c = (GhidraURLConnection) url.openConnection(); + Object obj = c.getContent(); // read-only access + if (c.getStatusCode() == StatusCode.UNAUTHORIZED) { + return; // assume user already notified + } + if (!(obj instanceof GhidraURLWrappedContent)) { + messageBadProgramURL(url); + return; + } + wrappedContent = (GhidraURLWrappedContent) obj; + content = wrappedContent.getContent(this); + if (!(content instanceof DomainFile)) { + messageBadProgramURL(url); + return; + } + df = (DomainFile) content; + + if (ProgramLinkContentHandler.PROGRAM_LINK_CONTENT_TYPE + .equals(df.getContentType())) { + Msg.showError(this, null, "Program Multi-Link Error", + "Multi-link Program access not supported: " + url); + return; + } + } + + if (!Program.class.isAssignableFrom(df.getDomainObjectClass())) { + Msg.showError(this, null, "Error Opening Program", + "File does not correspond to a Ghidra Program: " + df.getPathname()); + return; + } + + program = openProgram(df, url); + + } + catch (MalformedURLException e) { + Msg.showError(this, null, "Invalid Ghidra URL", + "Improperly formed Ghidra URL: " + url); + } + catch (IOException e) { + Msg.showError(this, null, "Program Open Failed", + "Failed to open Ghidra URL: " + e.getMessage()); + } + finally { + if (content != null) { + wrappedContent.release(content, this); + } + } + + if (program != null) { + openedProgramList.add(this); + } + } + + private void messageBadProgramURL(URL url) { + Msg.error("Invalid Ghidra URL", + "Ghidra URL does not reference a Ghidra Program: " + url); + } } } diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/util/viewer/field/Annotation.java b/Ghidra/Features/Base/src/main/java/ghidra/app/util/viewer/field/Annotation.java index cd0bcd5581..74ef0cadc6 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/util/viewer/field/Annotation.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/util/viewer/field/Annotation.java @@ -33,6 +33,7 @@ public class Annotation { private static final Pattern QUOTATION_PATTERN = Pattern.compile("(? ANNOTATED_STRING_HANDLERS; private static Map ANNOTATED_STRING_MAP; private String annotationText; @@ -40,6 +41,13 @@ public class Annotation { private AnnotatedStringHandler annotatedStringHandler; private AttributedString displayString; + public static List getAnnotatedStringHandlers() { + if (ANNOTATED_STRING_HANDLERS == null) { + ANNOTATED_STRING_HANDLERS = getSupportedAnnotationHandlers(); + } + return ANNOTATED_STRING_HANDLERS; + } + private static Map getAnnotatedStringHandlerMap() { if (ANNOTATED_STRING_MAP == null) { // lazy init due to our use of ClassSearcher ANNOTATED_STRING_MAP = createAnnotatedStringHandlerMap(); @@ -47,24 +55,28 @@ public class Annotation { return ANNOTATED_STRING_MAP; } - // locates AnnotatedStringHandler implementations to handle annotations private static Map createAnnotatedStringHandlerMap() { Map map = new HashMap<>(); - - // find all instances of AnnotatedString - List instances = - ClassSearcher.getInstances(AnnotatedStringHandler.class); - - for (AnnotatedStringHandler instance : instances) { + for (AnnotatedStringHandler instance : getAnnotatedStringHandlers()) { String[] supportedAnnotations = instance.getSupportedAnnotations(); for (String supportedAnnotation : supportedAnnotations) { map.put(supportedAnnotation, instance); } } - return Collections.unmodifiableMap(map); } + // locates AnnotatedStringHandler implementations to handle annotations + private static List getSupportedAnnotationHandlers() { + List list = new ArrayList<>(); + for (AnnotatedStringHandler h : ClassSearcher.getInstances(AnnotatedStringHandler.class)) { + if (h.getSupportedAnnotations().length != 0) { + list.add(h); + } + } + return Collections.unmodifiableList(list); + } + /** * Constructor * Note: This constructor assumes that the string starts with "{

    @
    " and ends with '}' @@ -184,14 +196,6 @@ public class Annotation { return annotationText; } - public static AnnotatedStringHandler[] getAnnotatedStringHandlers() { - Set annotations = - new HashSet<>(getAnnotatedStringHandlerMap().values()); - AnnotatedStringHandler[] retVal = new AnnotatedStringHandler[annotations.size()]; - annotations.toArray(retVal); - return retVal; - } - /*package*/ static Set getAnnotationNames() { return Collections.unmodifiableSet(getAnnotatedStringHandlerMap().keySet()); } diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/util/viewer/field/GhidraLocalURLAnnotatedStringHandler.java b/Ghidra/Features/Base/src/main/java/ghidra/app/util/viewer/field/GhidraLocalURLAnnotatedStringHandler.java new file mode 100644 index 0000000000..c04148eb0d --- /dev/null +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/util/viewer/field/GhidraLocalURLAnnotatedStringHandler.java @@ -0,0 +1,34 @@ +/* ### + * 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.app.util.viewer.field; + +/** + * This implementation expands {@link URLAnnotatedStringHandler} providing an example form + * of a local project Ghidra URL. + */ +public class GhidraLocalURLAnnotatedStringHandler extends URLAnnotatedStringHandler { + + @Override + public String getDisplayString() { + return "Ghidra-URL(local)"; + } + + @Override + public String getPrototypeString() { + return "{@url \"ghidra:/dirpath/myproject?/folder/program.exe#symbol\" \"display string\"}"; + } + +} diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/util/viewer/field/GhidraServerURLAnnotatedStringHandler.java b/Ghidra/Features/Base/src/main/java/ghidra/app/util/viewer/field/GhidraServerURLAnnotatedStringHandler.java new file mode 100644 index 0000000000..650c0f0696 --- /dev/null +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/util/viewer/field/GhidraServerURLAnnotatedStringHandler.java @@ -0,0 +1,34 @@ +/* ### + * 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.app.util.viewer.field; + +/** + * This implementation expands {@link URLAnnotatedStringHandler} providing an example form + * of a Ghidra Server URL. + */ +public class GhidraServerURLAnnotatedStringHandler extends URLAnnotatedStringHandler { + + @Override + public String getDisplayString() { + return "Ghidra-URL(remote)"; + } + + @Override + public String getPrototypeString() { + return "{@url \"ghidra://myserver/myrepo/folder/program.exe#symbol\" \"display string\"}"; + } + +} diff --git a/Ghidra/Features/Base/src/main/java/ghidra/app/util/viewer/field/URLAnnotatedStringHandler.java b/Ghidra/Features/Base/src/main/java/ghidra/app/util/viewer/field/URLAnnotatedStringHandler.java index 6895494fd3..f283829aaf 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/app/util/viewer/field/URLAnnotatedStringHandler.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/app/util/viewer/field/URLAnnotatedStringHandler.java @@ -36,8 +36,10 @@ import ghidra.util.Msg; * displayed. */ public class URLAnnotatedStringHandler implements AnnotatedStringHandler { + private static final String INVALID_SYMBOL_TEXT = - "@url annotation must have a URL string " + "optionally followed by a display string"; + "@url annotation must have a URL string optionally followed by a display string"; + private static final String[] SUPPORTED_ANNOTATIONS = { "url", "hyperlink", "href", "link" }; @Override diff --git a/Ghidra/Features/Base/src/main/java/ghidra/base/project/GhidraProject.java b/Ghidra/Features/Base/src/main/java/ghidra/base/project/GhidraProject.java index c5190dfd39..0a6546b321 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/base/project/GhidraProject.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/base/project/GhidraProject.java @@ -233,6 +233,13 @@ public class GhidraProject { return project; } + /** + * Returns the underlying ProjectData instance. + */ + public ProjectData getProjectData() { + return projectData; + } + /** * Closes the ghidra project, closing (without saving!) any open programs in * that project. Also deletes the project if created as a temporary project. diff --git a/Ghidra/Features/Base/src/main/java/ghidra/framework/main/DataTreeDialog.java b/Ghidra/Features/Base/src/main/java/ghidra/framework/main/DataTreeDialog.java index 53dd6ae165..9fa143dd82 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/framework/main/DataTreeDialog.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/framework/main/DataTreeDialog.java @@ -93,7 +93,10 @@ public class DataTreeDialog extends DialogComponentProvider private Integer treeSelectionMode; /** - * Construct a new DataTreeDialog. + * Construct a new DataTreeDialog. This chooser will show all project files. + * Following linked-folders will only be allowed if a type of {@link #CHOOSE_FOLDER} + * or {@link #OPEN} is specified. If different behavior is required a filter should + * be specified using the other constructor. * * @param parent dialog's parent * @param title title to use @@ -101,7 +104,7 @@ public class DataTreeDialog extends DialogComponentProvider * @throws IllegalArgumentException if invalid type is specified */ public DataTreeDialog(Component parent, String title, int type) { - this(parent, title, type, null); + this(parent, title, type, getDefaultFilter(type)); } /** @@ -119,6 +122,20 @@ public class DataTreeDialog extends DialogComponentProvider initDataTreeDialog(type, filter); } + private static DomainFileFilter getDefaultFilter(int type) { + if (type == CHOOSE_FOLDER || type == OPEN) { + // return filter which forces folder selection and allow navigation into linked-folders + return new DomainFileFilter() { + + @Override + public boolean accept(DomainFile df) { + return true; // show all files (legacy behavior) + } + }; + } + return null; + } + public void setTreeSelectionMode(int mode) { if (treePanel != null) { treePanel.getTreeSelectionModel().setSelectionMode(mode); diff --git a/Ghidra/Features/Base/src/main/java/ghidra/test/TestEnv.java b/Ghidra/Features/Base/src/main/java/ghidra/test/TestEnv.java index 869b939bd0..c37943595a 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/test/TestEnv.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/test/TestEnv.java @@ -18,6 +18,7 @@ package ghidra.test; import java.awt.Dialog; import java.awt.Window; import java.io.*; +import java.net.URL; import java.util.*; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; @@ -47,6 +48,7 @@ import ghidra.framework.plugintool.Plugin; import ghidra.framework.plugintool.PluginTool; import ghidra.framework.plugintool.util.PluginException; import ghidra.framework.project.DefaultProjectManager; +import ghidra.framework.protocol.ghidra.GhidraURL; import ghidra.program.database.ProgramDB; import ghidra.program.model.data.FileDataTypeManager; import ghidra.program.model.lang.*; @@ -874,26 +876,15 @@ public class TestEnv { * @param domainFile The domain file used to launch the tool; may be null * @return the tool that is launched */ - public PluginTool launchTool(final String toolName, final DomainFile domainFile) { + public PluginTool launchTool(String toolName, DomainFile domainFile) { AtomicReference ref = new AtomicReference<>(); AbstractGenericTest.runSwing(() -> { - boolean wasErrorGUIEnabled = AbstractDockingTest.isUseErrorGUI(); - AbstractDockingTest.setErrorGUIEnabled(false); // disable the error GUI while launching the tool - FrontEndTool frontEndToolInstance = getFrontEndTool(); - - Project project = frontEndToolInstance.getProject(); - ToolServices toolServices = project.getToolServices(); - PluginTool newTool = toolServices.launchTool(toolName, null); - if (newTool == null) { - // couldn't find the tool in the workspace...check the test area - newTool = launchDefaultToolByName(toolName); - } - + PluginTool newTool = doLaunchTool(toolName); ref.set(newTool); - - AbstractDockingTest.setErrorGUIEnabled(wasErrorGUIEnabled); - newTool.acceptDomainFiles(new DomainFile[] { domainFile }); + if (newTool != null) { + newTool.acceptDomainFiles(new DomainFile[] { domainFile }); + } }); PluginTool launchedTool = ref.get(); @@ -906,6 +897,52 @@ public class TestEnv { return launchedTool; } + /** + * Launches a tool of the given name using the given Ghidra URL. + *

    + * Note: the tool returned will have auto save disabled by default. + * + * @param toolName the name of the tool to launch + * @param ghidraUrl The Ghidra URL to be opened in tool (see {@link GhidraURL}) + * @return the tool that is launched + */ + public PluginTool launchToolWithURL(String toolName, URL ghidraUrl) { + AtomicReference ref = new AtomicReference<>(); + + AbstractGenericTest.runSwing(() -> { + PluginTool newTool = doLaunchTool(toolName); + ref.set(newTool); + if (newTool != null) { + newTool.accept(ghidraUrl); + } + }); + + PluginTool launchedTool = ref.get(); + if (launchedTool == null) { + throw new NullPointerException("Unable to launch the tool: " + toolName); + } + + // this will make sure that our tool is closed during disposal + extraTools.add(launchedTool); + return launchedTool; + } + + private PluginTool doLaunchTool(String toolName) { + boolean wasErrorGUIEnabled = AbstractDockingTest.isUseErrorGUI(); + AbstractDockingTest.setErrorGUIEnabled(false); // disable the error GUI while launching the tool + FrontEndTool frontEndToolInstance = getFrontEndTool(); + + Project project = frontEndToolInstance.getProject(); + ToolServices toolServices = project.getToolServices(); + PluginTool newTool = toolServices.launchTool(toolName, null); + if (newTool == null) { + // couldn't find the tool in the workspace...check the test area + newTool = launchDefaultToolByName(toolName); + } + AbstractDockingTest.setErrorGUIEnabled(wasErrorGUIEnabled); + return newTool; + } + /** * Sets the auto-save feature for all tool instances running under the {@link FrontEndTool} * created by this TestEnv instance. Auto-save is off by default when testing. diff --git a/Ghidra/Features/Base/src/main/resources/images/link.png b/Ghidra/Features/Base/src/main/resources/images/link.png deleted file mode 100644 index 1c654a0d61d28df9ccbabf44b23543c7b4ea3e3a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 410 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Ea{HEjtmUzPnffIy#(?lOI#yL zg7ec#$`gxH85~pclTsBta}(23gHjVyDhp4h+AuIM>Ug?1hE&{2`t$$4J+mrEiYW{Z3>$4~ zeiWR)zCQl4lCttm2}#L=O}oqAr~UZ-eZ8Hb9s`Gh8Y?TSq^zv0*aEgT{rG(iCPsJm z*Ux|W{QUg+9fm-sMC9b;ME&{ucV?fQ?J0xmZ*TseJ9n<<{=V8|2DOjxk4P{GIPgfD z=l%Kq<>lr7)22<^-}r~m)AQuo0|r0OA3L^UY@5~#K4t*Q2ZV&oxqSQf@A%l*zlIaeH|x0fa5FG*Ea`KOiK$^XkebhQ glWmCmF-900u6Dr>mdKI;Vst0E4WdEdT%j diff --git a/Ghidra/Features/Base/src/test.slow/java/ghidra/app/util/viewer/field/AnnotationTest.java b/Ghidra/Features/Base/src/test.slow/java/ghidra/app/util/viewer/field/AnnotationTest.java index 8bb7972354..b1d488d6bc 100644 --- a/Ghidra/Features/Base/src/test.slow/java/ghidra/app/util/viewer/field/AnnotationTest.java +++ b/Ghidra/Features/Base/src/test.slow/java/ghidra/app/util/viewer/field/AnnotationTest.java @@ -19,6 +19,8 @@ import static org.hamcrest.CoreMatchers.*; import static org.junit.Assert.*; import java.awt.*; +import java.net.MalformedURLException; +import java.net.URL; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -37,6 +39,8 @@ import ghidra.framework.model.*; import ghidra.framework.plugintool.ServiceProvider; import ghidra.framework.plugintool.TestDummyServiceProvider; import ghidra.framework.project.ProjectDataService; +import ghidra.framework.protocol.ghidra.GhidraURLConnection; +import ghidra.framework.store.FileSystem; import ghidra.program.database.ProgramBuilder; import ghidra.program.database.ProgramDB; import ghidra.program.model.address.Address; @@ -620,6 +624,60 @@ public class AnnotationTest extends AbstractGhidraHeadedIntegrationTest { assertTrue(spyNavigatable.navigatedTo(otherProgramPath, address)); } + @Test + public void testGhidraLocalUrlAnnotation_Program_WithAddress() { + + SpyNavigatable spyNavigatable = new SpyNavigatable(); + SpyServiceProvider spyServiceProvider = new SpyServiceProvider(); + + String addresstring = "1001000"; + + String pathname = "/a/b/prog"; + String url = "ghidra:/folder/project?" + pathname + "#" + addresstring; + String annotationText = "{@url \"" + url + "\"}"; + String rawComment = "My comment - " + annotationText; + AttributedString prototype = prototype(); + FieldElement element = + CommentUtils.parseTextForAnnotations(rawComment, program, prototype, 0); + + String displayString = element.getText(); + assertEquals("My comment - " + url, displayString); + + AnnotatedTextFieldElement annotatedElement = getAnnotatedTextFieldElement(element); + click(spyNavigatable, spyServiceProvider, annotatedElement); + + assertTrue(spyServiceProvider.programOpened(pathname)); + + // Navigation performed by ProgramManager not tested due to use of spyServiceProvider + } + + @Test + public void testGhidraServerUrlAnnotation_Program_WithAddress() { + + SpyNavigatable spyNavigatable = new SpyNavigatable(); + SpyServiceProvider spyServiceProvider = new SpyServiceProvider(); + + String addresstring = "1001000"; + + String pathname = "/a/b/prog"; + String url = "ghidra://server/repo" + pathname + "#" + addresstring; + String annotationText = "{@url \"" + url + "\"}"; + String rawComment = "My comment - " + annotationText; + AttributedString prototype = prototype(); + FieldElement element = + CommentUtils.parseTextForAnnotations(rawComment, program, prototype, 0); + + String displayString = element.getText(); + assertEquals("My comment - " + url, displayString); + + AnnotatedTextFieldElement annotatedElement = getAnnotatedTextFieldElement(element); + click(spyNavigatable, spyServiceProvider, annotatedElement); + + assertTrue(spyServiceProvider.programOpened(pathname)); + + // Navigation performed by ProgramManager not tested due to use of spyServiceProvider + } + @Test public void testUnknownAnnotation() { String rawComment = "This is a symbol {@syyyybol bob} annotation"; @@ -903,13 +961,7 @@ public class AnnotationTest extends AbstractGhidraHeadedIntegrationTest { private Set openedPrograms = new HashSet<>(); private Set closedPrograms = new HashSet<>(); - @Override - public Program openProgram(DomainFile domainFile, int version, int state) { - String name = domainFile.getName(); - String pathname = domainFile.getPathname(); - - openedPrograms.add(name); - + private Program generateProgram(String pathname, String name) { try { ProgramBuilder builder = new ProgramBuilder(); builder.setName(pathname); @@ -923,6 +975,39 @@ public class AnnotationTest extends AbstractGhidraHeadedIntegrationTest { } } + @Override + public Program openProgram(URL ghidraURL, int state) { + try { + GhidraURLConnection c = new GhidraURLConnection(ghidraURL); + String folderpath = c.getFolderPath(); + String name = c.getFolderItemName(); + String pathname = folderpath; + if (!pathname.endsWith(FileSystem.SEPARATOR)) { + pathname += FileSystem.SEPARATOR; + } + pathname += name; + openedPrograms.add(name); + + Program p = generateProgram(pathname, name); + + // NOTE: URL ref navigation not performed + + return p; + } + catch (MalformedURLException e) { + failWithException("Bad URL", e); + } + return null; + } + + @Override + public Program openProgram(DomainFile domainFile, int version, int state) { + String name = domainFile.getName(); + String pathname = domainFile.getPathname(); + openedPrograms.add(name); + return generateProgram(pathname, name); + } + @Override public boolean closeProgram(Program p, boolean ignoreChanges) { String name = FilenameUtils.getName(p.getName()); diff --git a/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/data/ProjectFileManagerTest.java b/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/data/ProjectFileManagerTest.java index 4a1a59efac..6512d66de2 100644 --- a/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/data/ProjectFileManagerTest.java +++ b/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/data/ProjectFileManagerTest.java @@ -121,12 +121,7 @@ public class ProjectFileManagerTest extends AbstractGhidraHeadedIntegrationTest // If there are queued actions, then we have to kick the handling thread and // let it finish running. - try { - assertTrue(eventManager.flushEvents(DEFAULT_WAIT_TIMEOUT, TimeUnit.MILLISECONDS)); - } - catch (InterruptedException e) { - failWithException("Interrupted waiting for filesystem events", e); - } + assertTrue(eventManager.flushEvents(DEFAULT_WAIT_TIMEOUT, TimeUnit.MILLISECONDS)); } private void deleteAll(File file) { diff --git a/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/main/DataTreeDialogTest.java b/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/main/DataTreeDialogTest.java index d0a768d832..19201b68cc 100644 --- a/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/main/DataTreeDialogTest.java +++ b/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/main/DataTreeDialogTest.java @@ -421,15 +421,10 @@ public class DataTreeDialogTest extends AbstractGhidraHeadedIntegrationTest { assertNotNull(dialog); } - private DomainFileFilter createStartsWithFilter(String startsWith) { - return (df) -> df.getName().startsWith(startsWith); - } - - private void showFiltered(String startsWith) { + private void showFiltered(final String startsWith) { SwingUtilities.invokeLater(() -> { dialog = new DataTreeDialog(frontEndTool.getToolFrame(), "Test Data Tree Dialog", - DataTreeDialog.OPEN, createStartsWithFilter(startsWith)); - + DataTreeDialog.OPEN, f -> f.getName().startsWith(startsWith)); dialog.showComponent(); }); waitForSwing(); diff --git a/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/project/AddViewToProjectTest.java b/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/project/AddViewToProjectTest.java index 35e42f2751..0b4cdd321a 100644 --- a/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/project/AddViewToProjectTest.java +++ b/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/project/AddViewToProjectTest.java @@ -79,10 +79,10 @@ public class AddViewToProjectTest extends AbstractGhidraHeadlessIntegrationTest try { URL view = GhidraURL.makeURL(DIRECTORY_NAME, PROJECT_VIEW1); - project.addProjectView(view); + project.addProjectView(view, true); // add another view that will be removed to test the remove - project.addProjectView(GhidraURL.makeURL(DIRECTORY_NAME, PROJECT_VIEW2)); + project.addProjectView(GhidraURL.makeURL(DIRECTORY_NAME, PROJECT_VIEW2), true); // validate the view was added to project ProjectLocator[] projViews = project.getProjectViews(); diff --git a/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/project/CreateDomainObjectTest.java b/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/project/CreateDomainObjectTest.java index 8cf67aa389..e13c9446f8 100644 --- a/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/project/CreateDomainObjectTest.java +++ b/Ghidra/Features/Base/src/test.slow/java/ghidra/framework/project/CreateDomainObjectTest.java @@ -117,7 +117,7 @@ public class CreateDomainObjectTest extends AbstractGhidraHeadedIntegrationTest project.close(); Project project2 = ProjectTestUtils.getProject(testDir, PROJECT_NAME2); try { - project2.addProjectView(GhidraURL.makeURL(testDir, PROJECT_NAME1)); + project2.addProjectView(GhidraURL.makeURL(testDir, PROJECT_NAME1), true); } catch (Exception e) { Assert.fail("View Not found"); diff --git a/Ghidra/Features/Base/src/test/java/ghidra/base/project/FakeRepository.java b/Ghidra/Features/Base/src/test/java/ghidra/base/project/FakeRepository.java index 594df2de14..15d421ea3e 100644 --- a/Ghidra/Features/Base/src/test/java/ghidra/base/project/FakeRepository.java +++ b/Ghidra/Features/Base/src/test/java/ghidra/base/project/FakeRepository.java @@ -15,15 +15,18 @@ */ package ghidra.base.project; +import java.io.File; import java.io.IOException; import java.util.HashMap; import java.util.Map; -import generic.test.TestUtils; +import generic.test.AbstractGTest; import ghidra.framework.model.DomainFile; import ghidra.framework.remote.User; +import ghidra.framework.store.local.IndexedV1LocalFileSystem; import ghidra.framework.store.local.LocalFileSystem; import ghidra.test.TestEnv; +import utilities.util.FileUtilities; /** * This class represents the idea of a shared Ghidra repository. This class is meant to be @@ -54,11 +57,34 @@ public class FakeRepository { private Map usersByName = new HashMap<>(); private Map projectsByUser = new HashMap<>(); + private File versionedFSDir; private LocalFileSystem versionedFileSystem; - public FakeRepository() { + public FakeRepository() throws IOException { // validation must be enabled if both environments are utilized by a test LocalFileSystem.setValidationRequired(); + + versionedFSDir = + new File(AbstractGTest.getTestDirectoryPath() + File.separator + "TestRepo.rep"); + if (versionedFSDir.exists()) { + FileUtilities.deleteDir(versionedFSDir); + } + if (versionedFSDir.exists() || !FileUtilities.createDir(versionedFSDir)) { + throw new IOException("Failed to create clean repo dir: " + versionedFSDir); + } + versionedFileSystem = new MyVersionedFileSystem(versionedFSDir.getPath()); + } + + private static class MyVersionedFileSystem extends IndexedV1LocalFileSystem { + MyVersionedFileSystem(String rootPath) throws IOException { + super(rootPath, true, false, true, true); + } + + @Override + public boolean isShared() { + // Enables use of asyncronous event dispatching thread + return true; + } } /** @@ -109,11 +135,6 @@ public class FakeRepository { FakeSharedProject project = new FakeSharedProject(this, user); projectsByUser.put(user, project); - - if (versionedFileSystem == null) { - versionedFileSystem = project.getVersionedFileSystem(); - TestUtils.setInstanceField("isShared", versionedFileSystem, Boolean.TRUE); - } return project; } @@ -139,6 +160,8 @@ public class FakeRepository { */ public void dispose() { projectsByUser.values().forEach(p -> disposeProject(p)); + versionedFileSystem.dispose(); + FileUtilities.deleteDir(versionedFSDir); } private void disposeProject(FakeSharedProject p) { diff --git a/Ghidra/Features/Base/src/test/java/ghidra/base/project/FakeSharedProject.java b/Ghidra/Features/Base/src/test/java/ghidra/base/project/FakeSharedProject.java index 2378fc9db0..075b87a862 100644 --- a/Ghidra/Features/Base/src/test/java/ghidra/base/project/FakeSharedProject.java +++ b/Ghidra/Features/Base/src/test/java/ghidra/base/project/FakeSharedProject.java @@ -25,7 +25,7 @@ import java.util.concurrent.TimeUnit; import org.apache.commons.lang3.StringUtils; -import generic.test.AbstractGenericTest; +import generic.test.AbstractGTest; import generic.test.TestUtils; import ghidra.framework.data.*; import ghidra.framework.model.*; @@ -39,6 +39,7 @@ import ghidra.test.TestProgramManager; import ghidra.util.exception.CancelledException; import ghidra.util.task.TaskMonitor; import junit.framework.AssertionFailedError; +import utilities.util.FileUtilities; /** * This class represents the idea of a shared Ghidra project. Each project is associated with @@ -61,21 +62,18 @@ public class FakeSharedProject { public FakeSharedProject(FakeRepository repo, User user) throws IOException { this.repo = repo; - String projectDirPath = AbstractGenericTest.getTestDirectoryPath(); + String projectDirPath = AbstractGTest.getTestDirectoryPath(); gProject = GhidraProject.createProject(projectDirPath, "TestProject_" + user.getName(), true); gProject.setDeleteOnClose(true); - LocalFileSystem fs = repo.getSharedFileSystem(); - if (fs != null) { - // first project will keeps its versioned file system - setVersionedFileSystem(fs); - } + // use local shared fake repo versioned file system + setVersionedFileSystem(repo.getSharedFileSystem()); } FakeSharedProject(User user) throws IOException { - String projectDirPath = AbstractGenericTest.getTestDirectoryPath(); + String projectDirPath = AbstractGTest.getTestDirectoryPath(); gProject = GhidraProject.createProject(projectDirPath, "TestProject_" + user.getName(), true); } @@ -101,7 +99,7 @@ public class FakeSharedProject { * @return the project file manager */ public ProjectFileManager getProjectFileManager() { - return (ProjectFileManager) gProject.getProject().getProjectData(); + return (ProjectFileManager) gProject.getProjectData(); } /** @@ -369,8 +367,11 @@ public class FakeSharedProject { * @see FakeRepository#dispose() */ public void dispose() { + ProjectLocator projectLocator = getProjectFileManager().getProjectLocator(); programManager.disposeOpenPrograms(); gProject.close(); + FileUtilities.deleteDir(projectLocator.getProjectDir()); + projectLocator.getMarkerFile().delete(); } @Override @@ -400,12 +401,7 @@ public class FakeSharedProject { (FileSystemEventManager) TestUtils.getInstanceField("eventManager", versionedFileSystem); - try { - eventManager.flushEvents(DEFAULT_WAIT_TIMEOUT, TimeUnit.MILLISECONDS); - } - catch (InterruptedException e) { - failWithException("Interrupted waiting for filesystem events", e); - } + eventManager.flushEvents(DEFAULT_WAIT_TIMEOUT, TimeUnit.MILLISECONDS); } private DomainFolder getFolder(String path) throws Exception { diff --git a/Ghidra/Features/FunctionID/ghidra_scripts/CollectFailedRelocations.java b/Ghidra/Features/FunctionID/ghidra_scripts/CollectFailedRelocations.java index da0da488a8..c72b79c3fc 100644 --- a/Ghidra/Features/FunctionID/ghidra_scripts/CollectFailedRelocations.java +++ b/Ghidra/Features/FunctionID/ghidra_scripts/CollectFailedRelocations.java @@ -99,6 +99,10 @@ public class CollectFailedRelocations extends GhidraScript { if (monitor.isCancelled()) { return; } + // Do not follow folder-links or consider program links. Using content type + // to filter is best way to control this. If program links should be considered + // "Program.class.isAssignableFrom(domainFile.getDomainObjectClass())" + // should be used. if (domainFile.getContentType().equals(ProgramContentHandler.PROGRAM_CONTENT_TYPE)) { programs.add(domainFile); } diff --git a/Ghidra/Features/FunctionID/ghidra_scripts/CreateMultipleLibraries.java b/Ghidra/Features/FunctionID/ghidra_scripts/CreateMultipleLibraries.java index 40fd19d123..9c3b8f3bfa 100644 --- a/Ghidra/Features/FunctionID/ghidra_scripts/CreateMultipleLibraries.java +++ b/Ghidra/Features/FunctionID/ghidra_scripts/CreateMultipleLibraries.java @@ -258,6 +258,10 @@ public class CreateMultipleLibraries extends GhidraScript { DomainFile[] files = myFolder.getFiles(); for (DomainFile domainFile : files) { monitor.checkCanceled(); + // Do not follow folder-links or consider program links. Using content type + // to filter is best way to control this. If program links should be considered + // "Program.class.isAssignableFrom(domainFile.getDomainObjectClass())" + // should be used. if (domainFile.getContentType().equals(ProgramContentHandler.PROGRAM_CONTENT_TYPE)) { programs.add(domainFile); } diff --git a/Ghidra/Features/FunctionID/ghidra_scripts/FidStatistics.java b/Ghidra/Features/FunctionID/ghidra_scripts/FidStatistics.java index 95b13d34d6..e1c6e5b688 100644 --- a/Ghidra/Features/FunctionID/ghidra_scripts/FidStatistics.java +++ b/Ghidra/Features/FunctionID/ghidra_scripts/FidStatistics.java @@ -372,6 +372,10 @@ public class FidStatistics extends GhidraScript { DomainFile[] files = folder.getFiles(); for (DomainFile domainFile : files) { monitor.checkCanceled(); + // Do not follow folder-links or consider program links. Using content type + // to filter is best way to control this. If program links should be considered + // "Program.class.isAssignableFrom(domainFile.getDomainObjectClass())" + // should be used. if (domainFile.getContentType().equals(ProgramContentHandler.PROGRAM_CONTENT_TYPE)) { programs.add(domainFile); } diff --git a/Ghidra/Features/FunctionID/ghidra_scripts/FindErrors.java b/Ghidra/Features/FunctionID/ghidra_scripts/FindErrors.java index 10a739e013..0f5e597a11 100644 --- a/Ghidra/Features/FunctionID/ghidra_scripts/FindErrors.java +++ b/Ghidra/Features/FunctionID/ghidra_scripts/FindErrors.java @@ -1,6 +1,5 @@ /* ### * IP: GHIDRA - * REVIEWED: YES * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,6 +16,9 @@ //Opens all programs under a chosen domain folder, grabs their error count, //then sorts in increasing error order and prints them //@category FunctionID +import java.io.IOException; +import java.util.*; + import generic.stl.Pair; import ghidra.app.script.GhidraScript; import ghidra.framework.model.DomainFile; @@ -27,9 +29,6 @@ import ghidra.util.Msg; import ghidra.util.exception.CancelledException; import ghidra.util.exception.VersionException; -import java.io.IOException; -import java.util.*; - public class FindErrors extends GhidraScript { @Override @@ -82,6 +81,10 @@ public class FindErrors extends GhidraScript { if (monitor.isCancelled()) { return; } + // Do not follow folder-links or consider program links. Using content type + // to filter is best way to control this. If program links should be considered + // "Program.class.isAssignableFrom(domainFile.getDomainObjectClass())" + // should be used. if (domainFile.getContentType().equals(ProgramContentHandler.PROGRAM_CONTENT_TYPE)) { programs.add(domainFile); } diff --git a/Ghidra/Features/FunctionID/ghidra_scripts/FindFunctionByHash.java b/Ghidra/Features/FunctionID/ghidra_scripts/FindFunctionByHash.java index 890204b81a..f9e7f0eb4c 100644 --- a/Ghidra/Features/FunctionID/ghidra_scripts/FindFunctionByHash.java +++ b/Ghidra/Features/FunctionID/ghidra_scripts/FindFunctionByHash.java @@ -100,6 +100,10 @@ public class FindFunctionByHash extends GhidraScript { if (monitor.isCancelled()) { return; } + // Do not follow folder-links or consider program links. Using content type + // to filter is best way to control this. If program links should be considered + // "Program.class.isAssignableFrom(domainFile.getDomainObjectClass())" + // should be used. if (domainFile.getContentType().equals(ProgramContentHandler.PROGRAM_CONTENT_TYPE)) { programs.add(domainFile); } diff --git a/Ghidra/Features/FunctionID/ghidra_scripts/FindNamedFunction.java b/Ghidra/Features/FunctionID/ghidra_scripts/FindNamedFunction.java index fb5ad98b2f..81acfa0be0 100644 --- a/Ghidra/Features/FunctionID/ghidra_scripts/FindNamedFunction.java +++ b/Ghidra/Features/FunctionID/ghidra_scripts/FindNamedFunction.java @@ -16,6 +16,9 @@ //Opens all programs under a chosen domain folder, scans them for functions //that match a user supplied name, and prints info about the match. //@category FunctionID +import java.io.IOException; +import java.util.ArrayList; + import ghidra.app.script.GhidraScript; import ghidra.feature.fid.service.FidService; import ghidra.framework.model.DomainFile; @@ -26,9 +29,6 @@ import ghidra.util.Msg; import ghidra.util.exception.CancelledException; import ghidra.util.exception.VersionException; -import java.io.IOException; -import java.util.ArrayList; - public class FindNamedFunction extends GhidraScript { FidService service; @@ -85,6 +85,10 @@ public class FindNamedFunction extends GhidraScript { if (monitor.isCancelled()) { return; } + // Do not follow folder-links or consider program links. Using content type + // to filter is best way to control this. If program links should be considered + // "Program.class.isAssignableFrom(domainFile.getDomainObjectClass())" + // should be used. if (domainFile.getContentType().equals(ProgramContentHandler.PROGRAM_CONTENT_TYPE)) { programs.add(domainFile); } diff --git a/Ghidra/Features/FunctionID/src/main/java/ghidra/feature/fid/plugin/IngestTask.java b/Ghidra/Features/FunctionID/src/main/java/ghidra/feature/fid/plugin/IngestTask.java index 3b9bd1cd04..ae3e50ba69 100644 --- a/Ghidra/Features/FunctionID/src/main/java/ghidra/feature/fid/plugin/IngestTask.java +++ b/Ghidra/Features/FunctionID/src/main/java/ghidra/feature/fid/plugin/IngestTask.java @@ -153,6 +153,10 @@ public class IngestTask extends Task { for (DomainFile domainFile : files) { monitor.checkCanceled(); monitor.incrementProgress(1); + // Do not follow folder-links or consider program links. Using content type + // to filter is best way to control this. If program links should be considered + // "Program.class.isAssignableFrom(domainFile.getDomainObjectClass())" + // should be used. if (domainFile.getContentType().equals(ProgramContentHandler.PROGRAM_CONTENT_TYPE)) { programs.add(domainFile); } diff --git a/Ghidra/Features/VersionTracking/src/main/java/ghidra/feature/vt/api/db/VTSessionDB.java b/Ghidra/Features/VersionTracking/src/main/java/ghidra/feature/vt/api/db/VTSessionDB.java index 8c1278cb7b..3dc956ef27 100644 --- a/Ghidra/Features/VersionTracking/src/main/java/ghidra/feature/vt/api/db/VTSessionDB.java +++ b/Ghidra/Features/VersionTracking/src/main/java/ghidra/feature/vt/api/db/VTSessionDB.java @@ -21,6 +21,7 @@ import java.util.concurrent.CopyOnWriteArrayList; import db.*; import ghidra.app.util.task.OpenProgramTask; +import ghidra.app.util.task.OpenProgramTask.OpenProgramRequest; import ghidra.feature.vt.api.correlator.program.ImpliedMatchProgramCorrelator; import ghidra.feature.vt.api.correlator.program.ManualMatchProgramCorrelator; import ghidra.feature.vt.api.impl.*; @@ -309,7 +310,8 @@ public class VTSessionDB extends DomainObjectAdapterDB implements VTSession, VTC TaskLauncher.launch(openTask); - return openTask.getOpenProgram(); + OpenProgramRequest openProgram = openTask.getOpenProgram(); + return openProgram != null ? openProgram.getProgram() : null; } @Override diff --git a/Ghidra/Features/VersionTracking/src/main/java/ghidra/feature/vt/api/impl/VTSessionContentHandler.java b/Ghidra/Features/VersionTracking/src/main/java/ghidra/feature/vt/api/impl/VTSessionContentHandler.java index ef12f97735..4c8abe14f0 100644 --- a/Ghidra/Features/VersionTracking/src/main/java/ghidra/feature/vt/api/impl/VTSessionContentHandler.java +++ b/Ghidra/Features/VersionTracking/src/main/java/ghidra/feature/vt/api/impl/VTSessionContentHandler.java @@ -24,7 +24,8 @@ import db.OpenMode; import db.buffers.BufferFile; import generic.theme.GIcon; import ghidra.feature.vt.api.db.VTSessionDB; -import ghidra.framework.data.*; +import ghidra.framework.data.DBContentHandler; +import ghidra.framework.data.DomainObjectMergeManager; import ghidra.framework.model.ChangeSet; import ghidra.framework.model.DomainObject; import ghidra.framework.store.*; @@ -34,7 +35,8 @@ import ghidra.util.exception.CancelledException; import ghidra.util.exception.VersionException; import ghidra.util.task.TaskMonitor; -public class VTSessionContentHandler extends DBContentHandler { +public class VTSessionContentHandler extends DBContentHandler { + private static Icon ICON = new GIcon("icon.version.tracking.session.content.type"); public final static String CONTENT_TYPE = "VersionTracking"; @@ -49,7 +51,6 @@ public class VTSessionContentHandler extends DBContentHandler { "Unsupported domain object: " + domainObject.getClass().getName()); } return createFile((VTSessionDB) domainObject, CONTENT_TYPE, fs, path, name, monitor); - } @Override @@ -74,7 +75,7 @@ public class VTSessionContentHandler extends DBContentHandler { } @Override - public DomainObjectAdapter getDomainObject(FolderItem item, FileSystem userfs, long checkoutId, + public VTSessionDB getDomainObject(FolderItem item, FileSystem userfs, long checkoutId, boolean okToUpgrade, boolean okToRecover, Object consumer, TaskMonitor monitor) throws IOException, CancelledException, VersionException { @@ -119,7 +120,7 @@ public class VTSessionContentHandler extends DBContentHandler { } @Override - public Class getDomainObjectClass() { + public Class getDomainObjectClass() { return VTSessionDB.class; } @@ -129,7 +130,7 @@ public class VTSessionContentHandler extends DBContentHandler { } @Override - public DomainObjectAdapter getImmutableObject(FolderItem item, Object consumer, int version, + public VTSessionDB getImmutableObject(FolderItem item, Object consumer, int version, int minChangeVersion, TaskMonitor monitor) throws IOException, CancelledException, VersionException { @@ -149,7 +150,7 @@ public class VTSessionContentHandler extends DBContentHandler { } @Override - public DomainObjectAdapter getReadOnlyObject(FolderItem item, int version, boolean okToUpgrade, + public VTSessionDB getReadOnlyObject(FolderItem item, int version, boolean okToUpgrade, Object consumer, TaskMonitor monitor) throws IOException, VersionException, CancelledException { diff --git a/Ghidra/Features/VersionTracking/src/main/java/ghidra/feature/vt/gui/actions/OpenVersionTrackingSessionAction.java b/Ghidra/Features/VersionTracking/src/main/java/ghidra/feature/vt/gui/actions/OpenVersionTrackingSessionAction.java index 4e176a6ee9..fe4ab3b24c 100644 --- a/Ghidra/Features/VersionTracking/src/main/java/ghidra/feature/vt/gui/actions/OpenVersionTrackingSessionAction.java +++ b/Ghidra/Features/VersionTracking/src/main/java/ghidra/feature/vt/gui/actions/OpenVersionTrackingSessionAction.java @@ -15,6 +15,10 @@ */ package ghidra.feature.vt.gui.actions; +import docking.ActionContext; +import docking.action.DockingAction; +import docking.action.MenuData; +import docking.tool.ToolConstants; import ghidra.feature.vt.api.main.VTSession; import ghidra.feature.vt.gui.plugin.VTController; import ghidra.feature.vt.gui.plugin.VTPlugin; @@ -23,10 +27,6 @@ import ghidra.framework.model.DomainFile; import ghidra.framework.model.DomainFileFilter; import ghidra.framework.plugintool.PluginTool; import ghidra.util.HelpLocation; -import docking.ActionContext; -import docking.action.DockingAction; -import docking.action.MenuData; -import docking.tool.ToolConstants; public class OpenVersionTrackingSessionAction extends DockingAction { @@ -60,5 +60,10 @@ public class OpenVersionTrackingSessionAction extends DockingAction { Class c = f.getDomainObjectClass(); return VTSession.class.isAssignableFrom(c); } + + @Override + public boolean followLinkedFolders() { + return false; + } } } diff --git a/Ghidra/Features/VersionTracking/src/main/java/ghidra/feature/vt/gui/wizard/NewSessionPanel.java b/Ghidra/Features/VersionTracking/src/main/java/ghidra/feature/vt/gui/wizard/NewSessionPanel.java index a0b69e6f8c..f93f0f527a 100644 --- a/Ghidra/Features/VersionTracking/src/main/java/ghidra/feature/vt/gui/wizard/NewSessionPanel.java +++ b/Ghidra/Features/VersionTracking/src/main/java/ghidra/feature/vt/gui/wizard/NewSessionPanel.java @@ -29,6 +29,7 @@ import docking.widgets.label.GDLabel; import docking.wizard.*; import generic.theme.*; import ghidra.app.util.task.OpenProgramTask; +import ghidra.app.util.task.OpenProgramTask.OpenProgramRequest; import ghidra.framework.main.DataTreeDialog; import ghidra.framework.model.DomainFile; import ghidra.framework.model.DomainFolder; @@ -383,8 +384,8 @@ public class NewSessionPanel extends AbstractMageJPanel { OpenProgramTask openProgramTask = new OpenProgramTask(programInfo.getFile(), tool); new TaskLauncher(openProgramTask, tool.getActiveWindow()); - Program program = openProgramTask.getOpenProgram(); - programInfo.setProgram(program); + OpenProgramRequest openProgram = openProgramTask.getOpenProgram(); + programInfo.setProgram(openProgram != null ? openProgram.getProgram() : null); } @Override diff --git a/Ghidra/Features/VersionTracking/src/main/java/ghidra/feature/vt/gui/wizard/VTWizardUtils.java b/Ghidra/Features/VersionTracking/src/main/java/ghidra/feature/vt/gui/wizard/VTWizardUtils.java index e338d08f75..d462548dd8 100644 --- a/Ghidra/Features/VersionTracking/src/main/java/ghidra/feature/vt/gui/wizard/VTWizardUtils.java +++ b/Ghidra/Features/VersionTracking/src/main/java/ghidra/feature/vt/gui/wizard/VTWizardUtils.java @@ -20,12 +20,12 @@ import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import docking.widgets.OptionDialog; -import ghidra.feature.vt.api.impl.VTSessionContentHandler; +import ghidra.feature.vt.api.main.VTSession; import ghidra.feature.vt.gui.task.SaveTask; import ghidra.framework.main.DataTreeDialog; import ghidra.framework.model.DomainFile; import ghidra.framework.model.DomainFileFilter; -import ghidra.program.database.ProgramDB; +import ghidra.program.model.listing.Program; import ghidra.util.HTMLUtilities; import ghidra.util.task.TaskLauncher; @@ -36,23 +36,20 @@ public class VTWizardUtils { } public static final DomainFileFilter VT_SESSION_FILTER = new DomainFileFilter() { + @Override public boolean accept(DomainFile df) { - if (VTSessionContentHandler.CONTENT_TYPE.equals(df.getContentType())) { - return true; - } + return VTSession.class.isAssignableFrom(df.getDomainObjectClass()); + } + + @Override + public boolean followLinkedFolders() { return false; } }; - public static final DomainFileFilter PROGRAM_FILTER = new DomainFileFilter() { - @Override - public boolean accept(DomainFile df) { - if (ProgramDB.CONTENT_TYPE.equals(df.getContentType())) { - return true; - } - return false; - } + public static final DomainFileFilter PROGRAM_FILTER = f -> { + return Program.class.isAssignableFrom(f.getDomainObjectClass()); }; static DomainFile chooseDomainFile(Component parent, String domainIdentifier, diff --git a/Ghidra/Framework/DB/src/main/java/db/buffers/BufferFileAdapter.java b/Ghidra/Framework/DB/src/main/java/db/buffers/BufferFileAdapter.java index a544eb9025..a9f4633155 100644 --- a/Ghidra/Framework/DB/src/main/java/db/buffers/BufferFileAdapter.java +++ b/Ghidra/Framework/DB/src/main/java/db/buffers/BufferFileAdapter.java @@ -16,8 +16,7 @@ package db.buffers; import java.io.IOException; -import java.rmi.NoSuchObjectException; -import java.rmi.Remote; +import java.rmi.*; import java.util.NoSuchElementException; import ghidra.util.Msg; @@ -104,8 +103,8 @@ public class BufferFileAdapter implements BufferFile { bufferFileHandle.dispose(); } catch (IOException e) { - // handle may have already been disposed - if (!(e instanceof NoSuchObjectException)) { + // handle may have already been disposed or disconnected + if (!(e instanceof NoSuchObjectException) && !(e instanceof ConnectException)) { Msg.error(this, e); } } diff --git a/Ghidra/Framework/Docking/src/main/java/docking/widgets/tree/GTreeNode.java b/Ghidra/Framework/Docking/src/main/java/docking/widgets/tree/GTreeNode.java index 33e742c1ac..9e4b071559 100644 --- a/Ghidra/Framework/Docking/src/main/java/docking/widgets/tree/GTreeNode.java +++ b/Ghidra/Framework/Docking/src/main/java/docking/widgets/tree/GTreeNode.java @@ -16,6 +16,7 @@ package docking.widgets.tree; import java.util.*; +import java.util.function.Predicate; import java.util.stream.Stream; import javax.swing.Icon; @@ -197,6 +198,22 @@ public abstract class GTreeNode extends CoreGTreeNode implements Comparable filter) { + for (GTreeNode node : children()) { + if (name.equals(node.getName()) && filter.test(node)) { + return node; + } + } + return null; + } + /** * Returns the child node at the given index. Returns null if the index is out of bounds. * @@ -488,6 +505,15 @@ public abstract class GTreeNode extends CoreGTreeNode implements Comparable= MAX) { return; @@ -57,6 +67,9 @@ public class GTreeExpandAllTask extends GTreeTask { if (parent.isLeaf()) { return; } + if (!force && !parent.isAutoExpandPermitted()) { + return; + } monitor.checkCanceled(); List allChildren = parent.getChildren(); if (allChildren.size() == 0) { @@ -68,9 +81,9 @@ public class GTreeExpandAllTask extends GTreeTask { } for (GTreeNode child : allChildren) { monitor.checkCanceled(); - expandNode(child, monitor); + expandNode(child, false, monitor); } - monitor.incrementProgress(1); + monitor.incrementProgress(1); // TODO: total node count is unknown } private void expandPath(final TreePath treePath, final TaskMonitor monitor) { diff --git a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/client/RepositoryAdapter.java b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/client/RepositoryAdapter.java index ef42c7f013..7e3d9fccfe 100644 --- a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/client/RepositoryAdapter.java +++ b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/client/RepositoryAdapter.java @@ -158,8 +158,10 @@ public class RepositoryAdapter implements RemoteAdapterListener { /** * Attempt to connect to the server. + * @throws RepositoryNotFoundException if named repository does not exist + * @throws IOException if IO error occurs */ - public void connect() throws IOException { + public void connect() throws RepositoryNotFoundException, IOException { synchronized (serverAdapter) { if (repository != null) { try { @@ -180,7 +182,7 @@ public class RepositoryAdapter implements RemoteAdapterListener { unexpectedDisconnect = false; if (repository == null) { noSuchRepository = true; - throw new IOException("Repository '" + name + "': not found"); + throw new RepositoryNotFoundException("Repository '" + name + "': not found"); } Msg.info(this, "Connected to repository '" + name + "'"); changeDispatcher.start(); diff --git a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/client/RepositoryNotFoundException.java b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/client/RepositoryNotFoundException.java new file mode 100644 index 0000000000..3b7903e8cc --- /dev/null +++ b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/client/RepositoryNotFoundException.java @@ -0,0 +1,31 @@ +/* ### + * 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.client; + +import java.io.IOException; + +/** + * {@code RepositoryNotFoundException} thrown when a failed connection occurs to a + * non-existing repository. A valid server connection is required to make this + * determination. + */ +public class RepositoryNotFoundException extends IOException { + + public RepositoryNotFoundException(String msg) { + super(msg); + } + +} diff --git a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/client/RepositoryServerAdapter.java b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/client/RepositoryServerAdapter.java index a2532eceae..ff21415bab 100644 --- a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/client/RepositoryServerAdapter.java +++ b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/client/RepositoryServerAdapter.java @@ -52,6 +52,9 @@ public class RepositoryServerAdapter { // Keeps track of whether the connection attempt was cancelled by the user private boolean connectCancelled = false; + // Keep track of last connect error + private Throwable lastConnectError; + private WeakSet listenerList = WeakDataStructureFactory.createCopyOnWriteWeakSet(); @@ -105,6 +108,14 @@ public class RepositoryServerAdapter { return connectCancelled; } + /** + * Returns the last error associated with a failed connection attempt. + * @return last connect error or null + */ + public Throwable getLastConnectError() { + return lastConnectError; + } + /** * Notify listeners of a connection state change. */ @@ -135,7 +146,7 @@ public class RepositoryServerAdapter { } } - Throwable cause = null; + lastConnectError = null; try { try { serverHandle = ClientUtil.connect(server); @@ -162,22 +173,22 @@ public class RepositoryServerAdapter { catch (LoginException e) { Msg.showError(this, null, "Server Error", "Server access denied (" + serverInfoStr + ")."); - cause = e; + lastConnectError = e; } catch (GeneralSecurityException e) { Msg.showError(this, null, "Server Error", "Server access denied (" + serverInfoStr + "): " + e.getMessage()); - cause = e; + lastConnectError = e; } catch (SocketTimeoutException | java.net.ConnectException | java.rmi.ConnectException e) { Msg.showError(this, null, "Server Error", "Connection to server failed (" + server + ")."); - cause = e; + lastConnectError = e; } catch (java.net.UnknownHostException | java.rmi.UnknownHostException e) { Msg.showError(this, null, "Server Error", "Server Not Found (" + server.getServerName() + ")."); - cause = e; + lastConnectError = e; } catch (RemoteException e) { String msg = e.getMessage(); @@ -185,7 +196,7 @@ public class RepositoryServerAdapter { while ((t = t.getCause()) != null) { String err = t.getMessage(); msg = err != null ? err : t.toString(); - cause = t; + lastConnectError = t; } Msg.showError(this, null, "Server Error", "An error occurred on the server (" + serverInfoStr + ").\n" + msg, e); @@ -200,7 +211,7 @@ public class RepositoryServerAdapter { "An error occurred while connecting to the server (" + serverInfoStr + ").\n" + msg, e); } - throw new NotConnectedException("Not connected to repository server", cause); + throw new NotConnectedException("Not connected to repository server", lastConnectError); } private void checkPasswordExpiration() { diff --git a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/remote/RepositoryHandle.java b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/remote/RepositoryHandle.java index c24dfbd022..f317923ae2 100644 --- a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/remote/RepositoryHandle.java +++ b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/remote/RepositoryHandle.java @@ -32,7 +32,7 @@ public interface RepositoryHandle { // TODO: NOTE! Debugging client or sever garbage collection delays could // cause handle to be disposed prematurely. - public final static int CLIENT_CHECK_PERIOD = SystemUtilities.isInTestingMode() ? 1000 : 30000; + public final static int CLIENT_CHECK_PERIOD = SystemUtilities.isInTestingMode() ? 2000 : 30000; /** * Returns the name of this repository. diff --git a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/FileSystemEventManager.java b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/FileSystemEventManager.java index 6df9e92f98..9026b62212 100644 --- a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/FileSystemEventManager.java +++ b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/FileSystemEventManager.java @@ -22,33 +22,60 @@ import java.util.concurrent.*; * FileSystemListenerList maintains a list of FileSystemListener's. * This class, acting as a FileSystemListener, simply relays each callback to * all FileSystemListener's within its list. Employs either a synchronous - * and asynchronous notification mechanism. + * and asynchronous notification mechanism. Once disposed event dispatching will + * discontinue. */ public class FileSystemEventManager implements FileSystemListener { + private static enum ThreadState { + STOPPED, RUNNING, DISPOSED + } + private List listeners = new CopyOnWriteArrayList<>(); private BlockingQueue eventQueue = new LinkedBlockingQueue<>(); - private volatile boolean disposed = false; + private final boolean asyncDispatchEnabled; + + private volatile ThreadState state = ThreadState.STOPPED; private Thread thread; /** * Constructor * @param enableAsynchronousDispatching if true a separate dispatch thread will be used - * to notify listeners. If false, blocking notification will be performed. + * to notify listeners. If false, blocking notification will be performed. Events are + * immediately discarded in the absence of any listener(s). */ public FileSystemEventManager(boolean enableAsynchronousDispatching) { + asyncDispatchEnabled = enableAsynchronousDispatching; + } - if (enableAsynchronousDispatching) { - thread = new FileSystemEventProcessingThread(); - thread.start(); + /** + * Return true if asynchornous event processing is enabled. + * @return true if asynchornous event processing is enabled, else false + */ + public boolean isAsynchronous() { + return asyncDispatchEnabled; + } + + /** + * Discontinue event dispatching and terminate dispatch thread if it exists. + */ + public synchronized void dispose() { + state = ThreadState.DISPOSED; + if (asyncDispatchEnabled) { + if (thread != null && thread.isAlive()) { + thread.interrupt(); + } + eventQueue.clear(); } } - public void dispose() { - disposed = true; - if (thread != null) { - thread.interrupt(); + private synchronized void startDispatchThread() { + if (asyncDispatchEnabled && state == ThreadState.STOPPED) { + // only starts when first listener is added + state = ThreadState.RUNNING; + thread = new FileSystemEventProcessingThread(); + thread.start(); } } @@ -57,6 +84,7 @@ public class FileSystemEventManager implements FileSystemListener { * @param listener the listener */ public void add(FileSystemListener listener) { + startDispatchThread(); // if asyncDispatchEnabled listeners.add(listener); } @@ -116,30 +144,34 @@ public class FileSystemEventManager implements FileSystemListener { @Override public void syncronize() { // Note: synchronize calls will only work when using a threaded event queue - if (isAsynchronous()) { - add(new SynchronizeEvent()); + if (asyncDispatchEnabled) { + queueEvent(new SynchronizeEvent()); } } - private boolean isAsynchronous() { - return thread != null; - } - - private void add(FileSystemEvent ev) { - if (!listeners.isEmpty()) { - eventQueue.add(ev); + /** + * Queue specified event if listener thread is running + * @param ev filesystm event + * @return true if queued, else false if listener thread not running + */ + private boolean queueEvent(FileSystemEvent ev) { + if (state == ThreadState.RUNNING) { + return eventQueue.add(ev); } + return false; } private void handleEvent(FileSystemEvent e) { - if (disposed) { + if (state == ThreadState.DISPOSED) { return; } - if (isAsynchronous()) { - add(e); + if (asyncDispatchEnabled) { + // if there are no listeners event will be discarded (i.e., listener thread not running) + queueEvent(e); } else { + // process in a synchronous fashion in current thread e.process(listeners); } } @@ -154,17 +186,26 @@ public class FileSystemEventManager implements FileSystemListener { * * @param timeout the maximum time to wait * @param unit the time unit of the {@code time} argument - * @return true if the events were processed in the given timeout - * @throws InterruptedException if this waiting thread is interrupted + * @return true if the events were processed in the given timeout. A false value will be + * returned if either a timeout occured */ - public boolean flushEvents(long timeout, TimeUnit unit) throws InterruptedException { - if (!isAsynchronous()) { + public boolean flushEvents(long timeout, TimeUnit unit) { + if (!asyncDispatchEnabled) { return true; // each thread processes its own event } MarkerEvent event = new MarkerEvent(); - eventQueue.add(event); - return event.waitForEvent(timeout, unit); + if (!queueEvent(event)) { + // events are not queuing since there are no listeners or dispose has occured + return true; + } + try { + return event.waitForEvent(timeout, unit); + } + catch (InterruptedException e) { + // ignore - listener thread stopped or disposed + return true; + } } //================================================================================================== @@ -180,18 +221,14 @@ public class FileSystemEventManager implements FileSystemListener { @Override public void run() { - while (!disposed) { - + while (state == ThreadState.RUNNING) { FileSystemEvent event; try { event = eventQueue.take(); event.process(listeners); } catch (InterruptedException e) { - // interrupt has been cleared; if other threads rely on this interrupted state, - // then mark the thread as interrupted again by calling: - // Thread.currentThread().interrupt(); - // For now, this code relies on the 'alive' flag to know when to terminate + // ignore - interrupt has been cleared } } } diff --git a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/local/IndexedLocalFileSystem.java b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/local/IndexedLocalFileSystem.java index a1dd2ffaf7..0222a5011c 100644 --- a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/local/IndexedLocalFileSystem.java +++ b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/local/IndexedLocalFileSystem.java @@ -916,6 +916,7 @@ public class IndexedLocalFileSystem extends LocalFileSystem { @Override public int getItemCount() throws IOException { + checkDisposed(); if (readOnly) { refreshReadOnlyIndex(); } @@ -930,11 +931,9 @@ public class IndexedLocalFileSystem extends LocalFileSystem { return count; } - /* - * @see ghidra.framework.store.FileSystem#getFolders(java.lang.String) - */ @Override public synchronized String[] getFolderNames(String folderPath) throws IOException { + checkDisposed(); if (readOnly) { refreshReadOnlyIndex(); } @@ -948,13 +947,12 @@ public class IndexedLocalFileSystem extends LocalFileSystem { } } - /* - * @see ghidra.framework.store.FileSystem#createFolder(java.lang.String, java.lang.String) - */ @Override public synchronized void createFolder(String parentPath, String folderName) throws InvalidNameException, IOException { + checkDisposed(); + if (readOnly) { throw new ReadOnlyException(); } @@ -987,12 +985,11 @@ public class IndexedLocalFileSystem extends LocalFileSystem { eventManager.folderCreated(parentPath, getName(path)); } - /* - * @see ghidra.framework.store.FileSystem#deleteFolder(java.lang.String) - */ @Override public synchronized void deleteFolder(String folderPath) throws IOException { + checkDisposed(); + if (readOnly) { throw new ReadOnlyException(); } @@ -1059,13 +1056,12 @@ public class IndexedLocalFileSystem extends LocalFileSystem { getListener().itemCreated(destFolderPath, itemName); } - /* - * @see ghidra.framework.store.FileSystem#moveItem(java.lang.String, java.lang.String, java.lang.String) - */ @Override public synchronized void moveItem(String folderPath, String name, String newFolderPath, String newName) throws IOException, InvalidNameException { + checkDisposed(); + if (readOnly) { throw new ReadOnlyException(); } @@ -1140,13 +1136,12 @@ public class IndexedLocalFileSystem extends LocalFileSystem { deleteEmptyVersionedFolders(folderPath); } - /* - * @see ghidra.framework.store.FileSystem#moveFolder(java.lang.String, java.lang.String, java.lang.String) - */ @Override public synchronized void moveFolder(String parentPath, String folderName, String newParentPath) throws InvalidNameException, IOException { + checkDisposed(); + if (readOnly) { throw new ReadOnlyException(); } @@ -1206,13 +1201,12 @@ public class IndexedLocalFileSystem extends LocalFileSystem { } } - /* - * @see ghidra.framework.store.FileSystem#renameFolder(java.lang.String, java.lang.String, java.lang.String) - */ @Override public synchronized void renameFolder(String parentPath, String folderName, String newFolderName) throws InvalidNameException, IOException { + checkDisposed(); + if (readOnly) { throw new ReadOnlyException(); } @@ -1247,16 +1241,14 @@ public class IndexedLocalFileSystem extends LocalFileSystem { eventManager.folderRenamed(parentPath, folderName, newFolderName); } - /* - * @see ghidra.framework.store.FileSystem#folderExists(java.lang.String) - */ @Override public synchronized boolean folderExists(String folderPath) { try { + checkDisposed(); getFolder(folderPath, GetFolderOption.READ_ONLY); return true; } - catch (NotFoundException e) { + catch (IOException | NotFoundException e) { return false; } } diff --git a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/local/IndexedV1LocalFileSystem.java b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/local/IndexedV1LocalFileSystem.java index 41bd6d3213..6c8cc9a542 100644 --- a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/local/IndexedV1LocalFileSystem.java +++ b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/store/local/IndexedV1LocalFileSystem.java @@ -46,7 +46,7 @@ public class IndexedV1LocalFileSystem extends IndexedLocalFileSystem { * @throws FileNotFoundException if specified rootPath does not exist * @throws IOException if error occurs while reading/writing index files */ - IndexedV1LocalFileSystem(String rootPath, boolean isVersioned, boolean readOnly, + protected IndexedV1LocalFileSystem(String rootPath, boolean isVersioned, boolean readOnly, boolean enableAsyncronousDispatching, boolean create) throws IOException { super(rootPath, isVersioned, readOnly, enableAsyncronousDispatching, create); } @@ -134,6 +134,7 @@ public class IndexedV1LocalFileSystem extends IndexedLocalFileSystem { @Override public FolderItem getItem(String fileID) throws IOException, UnsupportedOperationException { + checkDisposed(); if (fileIdMap == null) { return null; } 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 b569e2b940..c014e43ac8 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 @@ -71,6 +71,8 @@ public abstract class LocalFileSystem implements FileSystem { private static boolean refreshRequired = false; + private boolean disposed = false; + protected final File root; protected final boolean isVersioned; protected final boolean readOnly; @@ -78,9 +80,6 @@ public abstract class LocalFileSystem implements FileSystem { private RepositoryLogger repositoryLogger; - // Always false in production; can be manipulated by tests - private boolean isShared; - /** * Construct a local filesystem for existing data * @param rootPath @@ -285,25 +284,16 @@ public abstract class LocalFileSystem implements FileSystem { return refreshRequired; } - /* - * @see ghidra.framework.store.FileSystem#isVersioned() - */ @Override public boolean isVersioned() { return isVersioned; } - /* - * @see ghidra.framework.store.FileSystem#isOnline() - */ @Override public boolean isOnline() { - return true; + return !disposed; } - /* - * @see ghidra.framework.store.FileSystem#isReadOnly() - */ @Override public boolean isReadOnly() { return readOnly; @@ -388,9 +378,6 @@ public abstract class LocalFileSystem implements FileSystem { return getItemNames(folderPath, false); } - /* - * @see ghidra.framework.store.FileSystem#getItem(java.lang.String, java.lang.String) - */ @Override public synchronized LocalFolderItem getItem(String folderPath, String name) throws IOException { try { @@ -424,9 +411,6 @@ public abstract class LocalFileSystem implements FileSystem { throw new UnsupportedOperationException("getItem by File-ID"); } - /* - * @see ghidra.framework.store.FileSystem#createDatabase(java.lang.String, java.lang.String, java.lang.String, db.buffers.BufferFile, java.lang.String, java.lang.String, boolean, ghidra.util.task.TaskMonitor, java.lang.String) - */ @Override public synchronized LocalDatabaseItem createDatabase(String parentPath, String name, String fileID, BufferFile bufferFile, String comment, String contentType, @@ -483,9 +467,6 @@ public abstract class LocalFileSystem implements FileSystem { return item; } - /* - * @see ghidra.framework.store.FileSystem#createDatabase(java.lang.String, java.lang.String, java.lang.String, int, java.lang.String) - */ @Override public LocalManagedBufferFile createDatabase(String parentPath, String name, String fileID, String contentType, int bufferSize, String user, String projectPath) @@ -513,9 +494,6 @@ public abstract class LocalFileSystem implements FileSystem { return bufferFile; } - /* - * @see ghidra.framework.store.FileSystem#createDataFile(java.lang.String, java.lang.String, java.io.InputStream, java.lang.String, java.lang.String, ghidra.util.task.TaskMonitor) - */ @Override public synchronized LocalDataFile createDataFile(String parentPath, String name, InputStream istream, String comment, String contentType, TaskMonitor monitor) @@ -546,9 +524,6 @@ public abstract class LocalFileSystem implements FileSystem { return dataFile; } - /* - * @see ghidra.framework.store.FileSystem#createFile(java.lang.String, java.lang.String, java.io.File, ghidra.util.task.TaskMonitor, java.lang.String) - */ @Override public LocalDatabaseItem createFile(String parentPath, String name, File packedFile, TaskMonitor monitor, String user) @@ -591,9 +566,6 @@ public abstract class LocalFileSystem implements FileSystem { return item; } - /* - * @see ghidra.framework.store.FileSystem#moveItem(java.lang.String, java.lang.String, java.lang.String) - */ @Override public synchronized void moveItem(String folderPath, String name, String newFolderPath, String newName) throws IOException, InvalidNameException { @@ -652,9 +624,6 @@ public abstract class LocalFileSystem implements FileSystem { @Override public abstract boolean folderExists(String folderPath); - /* - * @see ghidra.framework.store.FileSystem#fileExists(java.lang.String, java.lang.String) - */ @Override public boolean fileExists(String folderPath, String name) { try { @@ -669,9 +638,6 @@ public abstract class LocalFileSystem implements FileSystem { } } - /* - * @see ghidra.framework.store.FileSystem#addFileSystemListener(ghidra.framework.store.FileSystemListener) - */ @Override public void addFileSystemListener(FileSystemListener listener) { if (eventManager != null) { @@ -679,9 +645,6 @@ public abstract class LocalFileSystem implements FileSystem { } } - /* - * @see ghidra.framework.store.FileSystem#removeFileSystemListener(ghidra.framework.store.FileSystemListener) - */ @Override public void removeFileSystemListener(FileSystemListener listener) { if (eventManager != null) { @@ -829,8 +792,7 @@ public abstract class LocalFileSystem implements FileSystem { @Override public boolean isShared() { - // Does not support direct sharing in production - return isShared; + return false; } // static void testValidPathLength(File file) throws IOException { @@ -846,6 +808,17 @@ public abstract class LocalFileSystem implements FileSystem { if (eventManager != null) { eventManager.dispose(); } + disposed = true; + } + + /** + * Check to see if file-system has been disposed. + * @throws IOException if file-system has been disposed + */ + protected void checkDisposed() throws IOException { + if (disposed) { + throw new IOException("File-system has been disposed"); + } } public boolean migrationInProgress() { diff --git a/Ghidra/Framework/FileSystem/src/test.slow/java/ghidra/framework/store/local/AbstractLocalFileSystemTest.java b/Ghidra/Framework/FileSystem/src/test.slow/java/ghidra/framework/store/local/AbstractLocalFileSystemTest.java index f74cfa8538..b65503a6ce 100644 --- a/Ghidra/Framework/FileSystem/src/test.slow/java/ghidra/framework/store/local/AbstractLocalFileSystemTest.java +++ b/Ghidra/Framework/FileSystem/src/test.slow/java/ghidra/framework/store/local/AbstractLocalFileSystemTest.java @@ -929,12 +929,7 @@ public abstract class AbstractLocalFileSystemTest extends AbstractGenericTest { FileSystemEventManager eventManager = (FileSystemEventManager) TestUtils.getInstanceField("eventManager", fs); - try { - eventManager.flushEvents(DEFAULT_WAIT_TIMEOUT, TimeUnit.MILLISECONDS); - } - catch (InterruptedException e) { - failWithException("Interrupted waiting for filesystem events", e); - } + eventManager.flushEvents(DEFAULT_WAIT_TIMEOUT, TimeUnit.MILLISECONDS); } } diff --git a/Ghidra/Framework/Generic/src/main/java/generic/test/AbstractGenericTest.java b/Ghidra/Framework/Generic/src/main/java/generic/test/AbstractGenericTest.java index 5d052c3e20..d2d318f7e4 100644 --- a/Ghidra/Framework/Generic/src/main/java/generic/test/AbstractGenericTest.java +++ b/Ghidra/Framework/Generic/src/main/java/generic/test/AbstractGenericTest.java @@ -507,6 +507,41 @@ public abstract class AbstractGenericTest extends AbstractGTest { return null; } + public static List findComponents(Container parent, + Class desiredClass) { + return findComponents(parent, desiredClass, false); + } + + public static List findComponents(Container parent, + Class desiredClass, boolean checkOwnedWindows) { + Component[] comps = parent.getComponents(); + List list = new ArrayList<>(); + for (Component element : comps) { + if (element == null) { + continue;// this started happening in 1.6, not sure why + } + if (desiredClass.isAssignableFrom(element.getClass())) { + list.add(desiredClass.cast(element)); + } + else if (element instanceof Container) { + T c = findComponent((Container) element, desiredClass, checkOwnedWindows); + if (c != null) { + list.add(desiredClass.cast(c)); + } + } + } + if (checkOwnedWindows && (parent instanceof Window)) { + Window[] windows = ((Window) parent).getOwnedWindows(); + for (int i = windows.length - 1; i >= 0; i--) { + Component c = findComponent(windows[i], desiredClass, checkOwnedWindows); + if (c != null) { + list.add(desiredClass.cast(c)); + } + } + } + return list; + } + /** * Get the first field object contained within object ownerInstance which * has the type classType. This method is only really useful if it is known diff --git a/Ghidra/Framework/Generic/src/main/java/generic/util/FileChannelLock.java b/Ghidra/Framework/Generic/src/main/java/generic/util/FileChannelLock.java index ccb2e52bdf..d80b7c2108 100644 --- a/Ghidra/Framework/Generic/src/main/java/generic/util/FileChannelLock.java +++ b/Ghidra/Framework/Generic/src/main/java/generic/util/FileChannelLock.java @@ -1,6 +1,5 @@ /* ### * IP: GHIDRA - * REVIEWED: YES * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,8 +16,7 @@ package generic.util; import java.io.*; -import java.nio.channels.FileChannel; -import java.nio.channels.FileLock; +import java.nio.channels.*; public class FileChannelLock { @@ -51,7 +49,7 @@ public class FileChannelLock { return isLocked; } - catch (IOException e) { + catch (IOException | OverlappingFileLockException e) { release(); } return false; diff --git a/Ghidra/Framework/Project/certification.manifest b/Ghidra/Framework/Project/certification.manifest index ec700e3b9d..2a05b72413 100644 --- a/Ghidra/Framework/Project/certification.manifest +++ b/Ghidra/Framework/Project/certification.manifest @@ -36,6 +36,7 @@ src/main/resources/images/disconnected.gif||GHIDRA||reviewed||END| src/main/resources/images/disk.png||FAMFAMFAM Icons - CC 2.5||||END| src/main/resources/images/face-glasses.png||Tango Icons - Public Domain|||tango icon set|END| src/main/resources/images/folder_add.png||FAMFAMFAM Icons - CC 2.5|||famfamfam silk icon set|END| +src/main/resources/images/link.png||Crystal Clear Icons - LGPL 2.1||||END| src/main/resources/images/lock.png||FAMFAMFAM Icons - CC 2.5|||famfamfam silk icon set|END| src/main/resources/images/monitor.png||FAMFAMFAM Icons - CC 2.5|||silk|END| src/main/resources/images/noneInTool.gif||GHIDRA||reviewed||END| diff --git a/Ghidra/Framework/Project/data/project.icons.theme.properties b/Ghidra/Framework/Project/data/project.icons.theme.properties index 5a8f6132d4..cf81d7ad06 100644 --- a/Ghidra/Framework/Project/data/project.icons.theme.properties +++ b/Ghidra/Framework/Project/data/project.icons.theme.properties @@ -6,10 +6,14 @@ icon.project.data.file.ghidra.unsupported = unknownFile.gif icon.project.data.file.ghidra.checked.out = icon.check icon.project.data.file.ghidra.checked.out.exclusive = checkex.png icon.project.data.file.ghidra.hijacked = small_hijack.gif -icon.project.data.file.ghidra.read.only = user-busy.png [size(10,10)] +icon.project.data.file.ghidra.read.only = user-busy.png [size(8,8)] icon.project.data.file.ghidra.not.latest = checkNotLatest.gif +icon.content.handler.link = link.png +icon.content.handler.link.overlay = EMPTY_ICON[size(16,16)]{icon.content.handler.link[move(0,8)]} // lower-left of 16x16 icon +icon.content.handler.linked.folder.open = icon.datatree.node.domain.folder.open{icon.content.handler.link.overlay} +icon.content.handler.linked.folder.closed = icon.datatree.node.domain.folder.closed{icon.content.handler.link.overlay} [Dark Defaults] diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/ContentHandler.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/ContentHandler.java index 59754c8e4d..9c48a7d8c6 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/ContentHandler.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/ContentHandler.java @@ -19,9 +19,7 @@ import java.io.IOException; import javax.swing.Icon; -import db.DBHandle; -import ghidra.framework.model.ChangeSet; -import ghidra.framework.model.DomainObject; +import ghidra.framework.model.*; import ghidra.framework.store.FileSystem; import ghidra.framework.store.FolderItem; import ghidra.util.InvalidNameException; @@ -31,15 +29,17 @@ import ghidra.util.exception.VersionException; import ghidra.util.task.TaskMonitor; /** - * NOTE: ALL ContentHandler CLASSES MUST END IN "ContentHandler". If not, + * NOTE: ALL ContentHandler implementations MUST END IN "ContentHandler". If not, * the ClassSearcher will not find them. * * ContentHandler defines an application interface for converting * between a specific domain object implementation and folder item storage. * This interface also defines a method which provides an appropriate icon * corresponding to the content. + * + * @param {@link DomainObjectAdapter} implementation class */ -public interface ContentHandler extends ExtensionPoint { +public interface ContentHandler extends ExtensionPoint { public static final String UNKNOWN_CONTENT = "Unknown-File"; public static final String MISSING_CONTENT = "Missing-File"; @@ -56,7 +56,8 @@ public interface ContentHandler extends ExtensionPoint { * @param domainObject the domain object to store in the newly created folder item * @param monitor the monitor that allows the user to cancel * @return checkout ID for new item - * @throws IOException if an i/o error occurs + * @throws IOException if an IO error occurs or an unsupported {@code domainObject} + * implementation is specified. * @throws InvalidNameException if the specified name contains invalid characters * @throws CancelledException if the user cancels */ @@ -77,12 +78,12 @@ public interface ContentHandler extends ExtensionPoint { * set. * @param monitor the monitor that allows the user to cancel * @return immutable domain object - * @throws IOException if a folder item access error occurs + * @throws IOException if an IO or folder item access error occurs * @throws CancelledException if operation is cancelled by user * @throws VersionException if unable to handle file content due to version * difference which could not be handled. */ - DomainObjectAdapter getImmutableObject(FolderItem item, Object consumer, int version, + T getImmutableObject(FolderItem item, Object consumer, int version, int minChangeVersion, TaskMonitor monitor) throws IOException, CancelledException, VersionException; @@ -98,12 +99,12 @@ public interface ContentHandler extends ExtensionPoint { * @param consumer consumer of the returned object * @param monitor the monitor that allows the user to cancel * @return read-only domain object - * @throws IOException if a folder item access error occurs + * @throws IOException if an IO or folder item access error occurs * @throws CancelledException if operation is cancelled by user * @throws VersionException if unable to handle file content due to version * difference which could not be handled. */ - DomainObjectAdapter getReadOnlyObject(FolderItem item, int version, boolean okToUpgrade, + T getReadOnlyObject(FolderItem item, int version, boolean okToUpgrade, Object consumer, TaskMonitor monitor) throws IOException, VersionException, CancelledException; @@ -121,12 +122,12 @@ public interface ContentHandler extends ExtensionPoint { * @param consumer consumer of the returned object * @param monitor cancelable task monitor * @return updateable domain object - * @throws IOException if a folder item access error occurs + * @throws IOException if an IO or folder item access error occurs * @throws CancelledException if operation is cancelled by user * @throws VersionException if unable to handle file content due to version * difference which could not be handled. */ - DomainObjectAdapter getDomainObject(FolderItem item, FileSystem userfs, long checkoutId, + T getDomainObject(FolderItem item, FileSystem userfs, long checkoutId, boolean okToUpgrade, boolean okToRecover, Object consumer, TaskMonitor monitor) throws IOException, CancelledException, VersionException; @@ -138,7 +139,7 @@ public interface ContentHandler extends ExtensionPoint { * @param newerVersion the newer version number * @return the set of changes that were made * @throws VersionException if a database version change prevents reading of data. - * @throws IOException if a folder item access error occurs or change set was + * @throws IOException if an IO or folder item access error occurs or change set was * produced by newer version of software and can not be read */ ChangeSet getChangeSet(FolderItem versionedFolderItem, int olderVersion, int newerVersion) @@ -161,55 +162,46 @@ public interface ContentHandler extends ExtensionPoint { /** * Returns true if the content type is always private * (i.e., can not be added to the versioned filesystem). + * @return true if private content type, else false */ boolean isPrivateContentType(); /** - * Returns list of unique content-types supported. - * A minimum of one content-type will be returned. If more than one - * is returned, these are considered equivalent aliases. + * Returns a unique content-type identifier + * @return content type identifier for associated domain object(s). */ String getContentType(); /** * A string that is meant to be presented to the user. + * @return user friendly content type for associated domain object(s). */ String getContentTypeDisplayString(); /** * Returns the Icon associated with this handlers content type. + * @return base icon to be used for a {@link DomainFile} with the associated content type. */ Icon getIcon(); /** - * Returns the name of the default tool that should be used to open this content type + * Returns the name of the default tool that should be used to open this content type. + * @return associated default tool for this content type */ String getDefaultToolName(); /** * Returns domain object implementation class supported. + * @return implementation class for the associated {@link DomainObjectAdapter} implementation. */ - Class getDomainObjectClass(); + Class getDomainObjectClass(); /** - * Create user data file associated with existing content. - * This facilitates the lazy creation of the user data file. - * @param associatedDomainObj associated domain object corresponding to this content handler - * @param userDbh user data handle - * @param userfs private user data filesystem - * @param monitor task monitor - * @throws IOException if an access error occurs - * @throws CancelledException if operation is cancelled by user + * If linking is supported return an instanceof the appropriate {@link LinkHandler}. + * @return corresponding link handler or null if not supported. */ - void saveUserDataFile(DomainObject associatedDomainObj, DBHandle userDbh, FileSystem userfs, - TaskMonitor monitor) throws CancelledException, IOException; - - /** - * Remove user data file associated with an existing folder item. - * @param item folder item - * @param userFilesystem - * @throws IOException if an access error occurs - */ - void removeUserDataFile(FolderItem item, FileSystem userFilesystem) throws IOException; + default LinkHandler getLinkHandler() { + return null; + } } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DBContentHandler.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DBContentHandler.java index b911cd998a..4b62da8926 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DBContentHandler.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DBContentHandler.java @@ -18,13 +18,10 @@ package ghidra.framework.data; import java.io.IOException; import db.DBHandle; -import db.buffers.BufferFile; import db.buffers.ManagedBufferFile; -import ghidra.framework.model.DomainFile; -import ghidra.framework.model.DomainObject; import ghidra.framework.store.*; -import ghidra.util.*; -import ghidra.util.exception.AssertException; +import ghidra.util.InvalidNameException; +import ghidra.util.SystemUtilities; import ghidra.util.exception.CancelledException; import ghidra.util.task.TaskMonitor; @@ -32,8 +29,11 @@ import ghidra.util.task.TaskMonitor; * DBContentHandler provides an abstract ContentHandler for * domain object content which is stored within a database file. * This class provides helper methods for working with database files. + * + * @param {@link DomainObjectAdapterDB} implementation class */ -public abstract class DBContentHandler implements ContentHandler { +public abstract class DBContentHandler + implements ContentHandler { /** * Create a new database file from an open database handle. @@ -70,6 +70,7 @@ public abstract class DBContentHandler implements ContentHandler { bf.delete(); } catch (IOException e) { + // ignore } abortCreate(fs, path, name, checkoutId); } @@ -77,7 +78,7 @@ public abstract class DBContentHandler implements ContentHandler { return checkoutId; } - private void abortCreate(FileSystem fs, String path, String name, long checkoutId) { + protected void abortCreate(FileSystem fs, String path, String name, long checkoutId) { try { FolderItem item = fs.getItem(path, name); if (item != null) { @@ -92,98 +93,4 @@ public abstract class DBContentHandler implements ContentHandler { } } - /** - * Return user data content type corresponding to associatedContentType. - */ - private static String getUserDataContentType(String associatedContentType) { - return associatedContentType + "UserData"; - } - - /** - * @see ghidra.framework.data.ContentHandler#saveUserDataFile(ghidra.framework.model.DomainObject, db.DBHandle, ghidra.framework.store.FileSystem, ghidra.util.task.TaskMonitor) - */ - @Override - public final void saveUserDataFile(DomainObject domainObj, DBHandle userDbh, FileSystem userfs, - TaskMonitor monitor) throws CancelledException, IOException { - if (userfs.isVersioned()) { - throw new IllegalArgumentException("User data file-system may not be versioned"); - } - String associatedContentType = getContentType(); - DomainFile associatedDf = domainObj.getDomainFile(); - if (associatedDf == null) { - throw new IllegalStateException("associated " + associatedContentType + - " file must be saved before user data can be saved"); - } - String associatedFileID = associatedDf.getFileID(); - if (associatedFileID == null) { - Msg.error(this, associatedContentType + " '" + associatedDf.getName() + - "' has not been assigned a file ID, user settings can not be saved!"); - return; - } - String path = "/"; - String name = ProjectFileManager.getUserDataFilename(associatedFileID); - BufferFile bf = null; - boolean success = false; - try { - bf = - userfs.createDatabase(path, name, FileIDFactory.createFileID(), - getUserDataContentType(associatedContentType), userDbh.getBufferSize(), - SystemUtilities.getUserName(), null); - userDbh.saveAs(bf, true, monitor); - success = true; - } - catch (InvalidNameException e) { - throw new AssertException("Unexpected Error", e); - } - finally { - if (bf != null && !success) { - try { - bf.delete(); - } - catch (IOException e) { - } - abortCreate(userfs, path, name, FolderItem.DEFAULT_CHECKOUT_ID); - } - } - } - - /** - * @see ghidra.framework.data.ContentHandler#removeUserDataFile(ghidra.framework.store.FolderItem, ghidra.framework.store.FileSystem) - */ - @Override - public final void removeUserDataFile(FolderItem associatedItem, FileSystem userfs) - throws IOException { - String path = "/"; - String name = ProjectFileManager.getUserDataFilename(associatedItem.getFileID()); - FolderItem item = userfs.getItem(path, name); - if (item != null) { - item.delete(-1, null); - } - } - - /** - * Open user data file associatedDbh - * @param associatedFileID - * @param associatedContentType - * @param userfs - * @param monitor - * @return user data file database handle - * @throws IOException - * @throws CancelledException - */ - protected final DBHandle openAssociatedUserFile(String associatedFileID, - String associatedContentType, FileSystem userfs, TaskMonitor monitor) - throws IOException, CancelledException { - String path = "/"; - String name = ProjectFileManager.getUserDataFilename(associatedFileID); - FolderItem item = userfs.getItem(path, name); - if (item == null || !(item instanceof DatabaseItem) || - !getUserDataContentType(associatedContentType).equals(item.getContentType())) { - return null; - } - DatabaseItem dbItem = (DatabaseItem) item; - BufferFile bf = dbItem.openForUpdate(FolderItem.DEFAULT_CHECKOUT_ID); - return new DBHandle(bf, false, monitor); - } - } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DBWithUserDataContentHandler.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DBWithUserDataContentHandler.java new file mode 100644 index 0000000000..84738a3077 --- /dev/null +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DBWithUserDataContentHandler.java @@ -0,0 +1,144 @@ +/* ### + * 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 java.io.IOException; + +import db.DBHandle; +import db.buffers.BufferFile; +import ghidra.framework.model.DomainFile; +import ghidra.framework.model.DomainObject; +import ghidra.framework.store.*; +import ghidra.util.*; +import ghidra.util.exception.AssertException; +import ghidra.util.exception.CancelledException; +import ghidra.util.task.TaskMonitor; + +/** + * DBContentHandler provides an abstract ContentHandler for + * domain object content which is stored within a database file. + * This class provides helper methods for working with database files. + * + * @param {@link DomainObjectAdapterDB} implementation class + */ +public abstract class DBWithUserDataContentHandler + extends DBContentHandler { + + /** + * Return user data content type corresponding to associatedContentType. + */ + private static String getUserDataContentType(String associatedContentType) { + return associatedContentType + "UserData"; + } + + /** + * Create user data file associated with existing content. + * This facilitates the lazy creation of the user data file. + * @param associatedDomainObj associated domain object corresponding to this content handler + * @param userDbh user data handle + * @param userfs private user data filesystem + * @param monitor task monitor + * @throws IOException if an IO or access error occurs + * @throws CancelledException if operation is cancelled by user + */ + public final void saveUserDataFile(DomainObject associatedDomainObj, DBHandle userDbh, + FileSystem userfs, + TaskMonitor monitor) throws CancelledException, IOException { + if (userfs.isVersioned()) { + throw new IllegalArgumentException("User data file-system may not be versioned"); + } + String associatedContentType = getContentType(); + DomainFile associatedDf = associatedDomainObj.getDomainFile(); + if (associatedDf == null) { + throw new IllegalStateException("associated " + associatedContentType + + " file must be saved before user data can be saved"); + } + String associatedFileID = associatedDf.getFileID(); + if (associatedFileID == null) { + Msg.error(this, associatedContentType + " '" + associatedDf.getName() + + "' has not been assigned a file ID, user settings can not be saved!"); + return; + } + String path = "/"; + String name = ProjectFileManager.getUserDataFilename(associatedFileID); + BufferFile bf = null; + boolean success = false; + try { + bf = + userfs.createDatabase(path, name, FileIDFactory.createFileID(), + getUserDataContentType(associatedContentType), userDbh.getBufferSize(), + SystemUtilities.getUserName(), null); + userDbh.saveAs(bf, true, monitor); + success = true; + } + catch (InvalidNameException e) { + throw new AssertException("Unexpected Error", e); + } + finally { + if (bf != null && !success) { + try { + bf.delete(); + } + catch (IOException e) { + // ignore + } + abortCreate(userfs, path, name, FolderItem.DEFAULT_CHECKOUT_ID); + } + } + } + + /** + * Remove user data file associated with an existing folder item. + * @param associatedItem associated folder item + * @param userFilesystem user data file system from which corresponding data should be removed. + * @throws IOException if an access error occurs + */ + public final void removeUserDataFile(FolderItem associatedItem, FileSystem userFilesystem) + throws IOException { + String path = "/"; + String name = ProjectFileManager.getUserDataFilename(associatedItem.getFileID()); + FolderItem item = userFilesystem.getItem(path, name); + if (item != null) { + item.delete(-1, null); + } + } + + /** + * Open user data file associatedDbh + * @param associatedFileID + * @param associatedContentType + * @param userfs + * @param monitor + * @return user data file database handle + * @throws IOException + * @throws CancelledException + */ + protected final DBHandle openAssociatedUserFile(String associatedFileID, + String associatedContentType, FileSystem userfs, TaskMonitor monitor) + throws IOException, CancelledException { + String path = "/"; + String name = ProjectFileManager.getUserDataFilename(associatedFileID); + FolderItem item = userfs.getItem(path, name); + if (item == null || !(item instanceof DatabaseItem) || + !getUserDataContentType(associatedContentType).equals(item.getContentType())) { + return null; + } + DatabaseItem dbItem = (DatabaseItem) item; + BufferFile bf = dbItem.openForUpdate(FolderItem.DEFAULT_CHECKOUT_ID); + return new DBHandle(bf, false, monitor); + } + +} diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DomainFileProxy.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DomainFileProxy.java index dece247192..7a13516e27 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DomainFileProxy.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DomainFileProxy.java @@ -17,11 +17,14 @@ package ghidra.framework.data; import java.io.File; import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; import java.util.*; import javax.swing.Icon; import ghidra.framework.model.*; +import ghidra.framework.protocol.ghidra.GhidraURL; import ghidra.framework.store.ItemCheckoutStatus; import ghidra.framework.store.Version; import ghidra.framework.store.db.PackedDatabase; @@ -111,6 +114,29 @@ public class DomainFileProxy implements DomainFile { return parentPath + DomainFolder.SEPARATOR + getName(); } + @Override + public URL getSharedProjectURL() { + if (projectLocation != null && version == DomainFile.DEFAULT_VERSION) { + URL projectURL = projectLocation.getURL(); + if (GhidraURL.isServerRepositoryURL(projectURL)) { + try { + // Direct URL construction done so that ghidra protocol + // extension may be supported + String urlStr = projectURL.toExternalForm(); + if (urlStr.endsWith("/")) { + urlStr = urlStr.substring(0, urlStr.length() - 1); + } + urlStr += getPathname(); + return new URL(urlStr); + } + catch (MalformedURLException e) { + // ignore + } + } + } + return null; + } + @Override public int compareTo(DomainFile df) { return getName().compareToIgnoreCase(df.getName()); @@ -143,7 +169,7 @@ public class DomainFileProxy implements DomainFile { DomainObjectAdapter dobj = getDomainObject(); if (dobj != null) { try { - ContentHandler ch = DomainObjectAdapter.getContentHandler(dobj); + ContentHandler ch = DomainObjectAdapter.getContentHandler(dobj); return ch.getContentType(); } catch (IOException e) { @@ -153,6 +179,27 @@ public class DomainFileProxy implements DomainFile { return "Unknown File"; } + @Override + public boolean isLinkFile() { + DomainObjectAdapter dobj = getDomainObject(); + if (dobj != null) { + ContentHandler ch; + try { + ch = DomainObjectAdapter.getContentHandler(dobj); + return LinkHandler.class.isAssignableFrom(ch.getClass()); + } + catch (IOException e) { + // ignore + } + } + return false; + } + + @Override + public DomainFolder followLink() { + throw new UnsupportedOperationException(); + } + @Override public Class getDomainObjectClass() { DomainObjectAdapter dobj = getDomainObject(); @@ -228,6 +275,7 @@ public class DomainFileProxy implements DomainFile { dobj.release(consumer); } catch (IllegalArgumentException e) { + // ignore unknown consumer error } } } @@ -256,11 +304,6 @@ public class DomainFileProxy implements DomainFile { throw new UnsupportedOperationException("Repository operations not supported"); } - @Override - public boolean isVersionControlSupported() { - return false; - } - @Override public boolean canAddToRepository() { return false; @@ -303,6 +346,16 @@ public class DomainFileProxy implements DomainFile { throw new UnsupportedOperationException("Repository operations not supported"); } + @Override + public boolean isLinkingSupported() { + return false; + } + + @Override + public DomainFile copyToAsLink(DomainFolder newParent) throws IOException { + return null; // not supported by proxy file + } + @Override public DomainFile copyTo(DomainFolder newParent, TaskMonitor monitor) throws IOException, CancelledException { @@ -318,11 +371,8 @@ public class DomainFileProxy implements DomainFile { } } - /** - * @see ghidra.framework.model.DomainFile#copyVersionTo(int, ghidra.framework.model.DomainFolder, ghidra.util.task.TaskMonitor) - */ @Override - public DomainFile copyVersionTo(int version, DomainFolder destFolder, TaskMonitor monitor) + public DomainFile copyVersionTo(int ver, DomainFolder destFolder, TaskMonitor monitor) throws IOException, CancelledException { throw new UnsupportedOperationException("copyVersionTo unsupported for DomainFileProxy"); } @@ -420,7 +470,7 @@ public class DomainFileProxy implements DomainFile { throw new UnsupportedOperationException("packFile() only valid for Database files"); } DomainObjectAdapterDB dbObj = (DomainObjectAdapterDB) domainObj; - ContentHandler ch = DomainObjectAdapter.getContentHandler(domainObj); + ContentHandler ch = DomainObjectAdapter.getContentHandler(domainObj); PackedDatabase.packDatabase(dbObj.getDBHandle(), dbObj.getName(), ch.getContentType(), file, monitor); } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DomainObjectAdapter.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DomainObjectAdapter.java index adc0c0d95c..58c161c7f8 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DomainObjectAdapter.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DomainObjectAdapter.java @@ -38,8 +38,8 @@ public abstract class DomainObjectAdapter implements DomainObject { protected final static String DEFAULT_NAME = "untitled"; private static Class defaultDomainObjClass; // Domain object implementation mapped to unknown content type - private static HashMap contentHandlerTypeMap; // maps content-type string to handler - private static HashMap, ContentHandler> contentHandlerClassMap; // maps domain object class to handler + private static HashMap> contentHandlerTypeMap; // maps content-type string to handler + private static HashMap, ContentHandler> contentHandlerClassMap; // maps domain object class to handler private static ChangeListener contentHandlerUpdateListener = new ChangeListener() { @Override public void stateChanged(ChangeEvent e) { @@ -399,7 +399,7 @@ public abstract class DomainObjectAdapter implements DomainObject { contentHandlerTypeMap.remove(null); } else { - ContentHandler ch = contentHandlerClassMap.get(doClass); + ContentHandler ch = contentHandlerClassMap.get(doClass); if (ch != null) { contentHandlerTypeMap.put(null, ch); } @@ -414,9 +414,9 @@ public abstract class DomainObjectAdapter implements DomainObject { * @return content handler * @throws IOException if no content handler can be found */ - static synchronized ContentHandler getContentHandler(String contentType) throws IOException { + static synchronized ContentHandler getContentHandler(String contentType) throws IOException { checkContentHandlerMaps(); - ContentHandler ch = contentHandlerTypeMap.get(contentType); + ContentHandler ch = contentHandlerTypeMap.get(contentType); if (ch == null) { throw new IOException("Content handler not found for " + contentType); } @@ -430,10 +430,10 @@ public abstract class DomainObjectAdapter implements DomainObject { * @return content handler * @throws IOException if no content handler can be found */ - public static synchronized ContentHandler getContentHandler(DomainObject dobj) + public static synchronized ContentHandler getContentHandler(DomainObject dobj) throws IOException { checkContentHandlerMaps(); - ContentHandler ch = contentHandlerClassMap.get(dobj.getClass()); + ContentHandler ch = contentHandlerClassMap.get(dobj.getClass()); if (ch == null) { throw new IOException("Content handler not found for " + dobj.getClass().getName()); } @@ -450,17 +450,15 @@ public abstract class DomainObjectAdapter implements DomainObject { } private synchronized static void getContentHandlers() { - contentHandlerClassMap = new HashMap, ContentHandler>(); - contentHandlerTypeMap = new HashMap(); + contentHandlerClassMap = new HashMap, ContentHandler>(); + contentHandlerTypeMap = new HashMap>(); + @SuppressWarnings("rawtypes") List handlers = ClassSearcher.getInstances(ContentHandler.class); - for (ContentHandler ch : handlers) { - String type = ch.getContentType(); - Class DOClass = ch.getDomainObjectClass(); - if (type != null && DOClass != null) { - contentHandlerClassMap.put(DOClass, ch); - contentHandlerTypeMap.put(type, ch); - continue; + for (ContentHandler ch : handlers) { + contentHandlerTypeMap.put(ch.getContentType(), ch); + if (!(ch instanceof LinkHandler)) { + contentHandlerClassMap.put(ch.getDomainObjectClass(), ch); } } setDefaultContentClass(defaultDomainObjClass); diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/FolderLinkContentHandler.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/FolderLinkContentHandler.java new file mode 100644 index 0000000000..0dccc72cf9 --- /dev/null +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/FolderLinkContentHandler.java @@ -0,0 +1,119 @@ +/* ### + * 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 java.io.IOException; +import java.net.URL; + +import javax.swing.Icon; + +import ghidra.framework.main.AppInfo; +import ghidra.framework.model.*; +import ghidra.framework.store.FileSystem; +import ghidra.util.InvalidNameException; +import ghidra.util.exception.CancelledException; +import ghidra.util.task.TaskMonitor; + +/** + * {@code FolderLinkContentHandler} provide folder-link support. + * Implementation relies on {@link AppInfo#getActiveProject()} to provide life-cycle + * management for related transient-projects opened while following folder-links. + */ +public class FolderLinkContentHandler extends LinkHandler { + + public static FolderLinkContentHandler INSTANCE = new FolderLinkContentHandler(); + + public static final String FOLDER_LINK_CONTENT_TYPE = "FolderLink"; + + @Override + public long createFile(FileSystem fs, FileSystem userfs, String path, String name, + DomainObject obj, TaskMonitor monitor) + throws IOException, InvalidNameException, CancelledException { + if (!(obj instanceof URLLinkObject)) { + throw new IOException("Unsupported domain object: " + obj.getClass().getName()); + } + return createFile((URLLinkObject) obj, FOLDER_LINK_CONTENT_TYPE, fs, path, name, + monitor); + } + + @Override + public String getContentType() { + return FOLDER_LINK_CONTENT_TYPE; + } + + @Override + public String getContentTypeDisplayString() { + return FOLDER_LINK_CONTENT_TYPE; + } + + @Override + public Class getDomainObjectClass() { + return NullFolderDomainObject.class; // special case since link corresponds to a Domain Folder + } + + @Override + public Icon getIcon() { + return DomainFolder.CLOSED_FOLDER_ICON; + } + + @Override + public String getDefaultToolName() { + return null; + } + + /** + * Get linked domain folder + * @param folderLinkFile folder-link file. + * @return {@link LinkedGhidraFolder} referenced by specified folder-link file or null if + * folderLinkFile content type is not {@value #FOLDER_LINK_CONTENT_TYPE}. + * @throws IOException if an IO or folder item access error occurs + */ + public static LinkedGhidraFolder getReadOnlyLinkedFolder(DomainFile folderLinkFile) + throws IOException { + + if (!FOLDER_LINK_CONTENT_TYPE.equals(folderLinkFile.getContentType())) { + return null; + } + + URL url = getURL(folderLinkFile); + + Project activeProject = AppInfo.getActiveProject(); + GhidraFolder parent = ((GhidraFile) folderLinkFile).getParent(); + return new LinkedGhidraFolder(activeProject, parent, folderLinkFile.getName(), url); + } + +} + +/** + * Dummy domain object to satisfy {@link FolderLinkContentHandler#getDomainObjectClass()} + */ +final class NullFolderDomainObject extends DomainObjectAdapterDB { + private NullFolderDomainObject() { + // this object may not be instantiated + super(null, null, 0, NullFolderDomainObject.class); + throw new RuntimeException("Object may not be instantiated"); + } + + @Override + public boolean isChangeable() { + return false; + } + + @Override + public String getDescription() { + return "Dummy FolderLink Domain Object"; + } +} 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 a2f6b78ea6..b784e944e2 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 @@ -16,6 +16,7 @@ package ghidra.framework.data; import java.io.*; +import java.net.URL; import java.util.*; import javax.swing.Icon; @@ -24,8 +25,7 @@ import ghidra.framework.model.*; import ghidra.framework.store.ItemCheckoutStatus; import ghidra.framework.store.Version; import ghidra.framework.store.local.LocalFileSystem; -import ghidra.util.InvalidNameException; -import ghidra.util.ReadOnlyException; +import ghidra.util.*; import ghidra.util.exception.*; import ghidra.util.task.TaskMonitor; @@ -123,6 +123,17 @@ public class GhidraFile implements DomainFile { return fileManager.getProjectLocator(); } + @Override + public URL getSharedProjectURL() { + try { + return getFileData().getSharedProjectURL(); + } + catch (IOException e) { + // ignore + } + return null; + } + @Override public String getContentType() { try { @@ -134,6 +145,37 @@ public class GhidraFile implements DomainFile { return ContentHandler.UNKNOWN_CONTENT; } + @Override + public boolean isLinkFile() { + try { + return getFileData().isLinkFile(); + } + catch (IOException e) { + return false; + } + } + + @Override + public DomainFolder followLink() { + try { + return FolderLinkContentHandler.getReadOnlyLinkedFolder(this); + } + catch (IOException e) { + Msg.error(this, "Failed to following folder-link: " + getPathname()); + } + return null; + } + + @Override + public boolean isLinkingSupported() { + try { + return getFileData().isLinkingSupported(); + } + catch (IOException e) { + return false; + } + } + @Override public Class getDomainObjectClass() { try { @@ -146,7 +188,7 @@ public class GhidraFile implements DomainFile { } @Override - public DomainFolder getParent() { + public GhidraFolder getParent() { return parent; } @@ -257,7 +299,7 @@ public class GhidraFile implements DomainFile { catch (IOException e) { fileError(e); } - return GhidraFileData.UNSUPPORTED_FILE_ICON; + return UNSUPPORTED_FILE_ICON; } @Override @@ -353,17 +395,6 @@ public class GhidraFile implements DomainFile { return true; } - @Override - public boolean isVersionControlSupported() { - try { - return getFileData().isVersionControlSupported(); - } - catch (IOException e) { - fileError(e); - } - return false; - } - @Override public boolean isVersioned() { try { @@ -482,6 +513,9 @@ public class GhidraFile implements DomainFile { @Override public GhidraFile moveTo(DomainFolder newParent) throws IOException { + if (!GhidraFolder.class.isAssignableFrom(newParent.getClass())) { + throw new UnsupportedOperationException("newParent does not support moveTo"); + } GhidraFolder newGhidraParent = (GhidraFolder) newParent; return getFileData().moveTo(newGhidraParent.getFolderData()); } @@ -489,15 +523,30 @@ public class GhidraFile implements DomainFile { @Override public DomainFile copyTo(DomainFolder newParent, TaskMonitor monitor) throws IOException, CancelledException { - GhidraFolder newGhidraParent = (GhidraFolder) newParent; // assumes single implementation + if (!GhidraFolder.class.isAssignableFrom(newParent.getClass())) { + throw new UnsupportedOperationException("newParent does not support copyTo"); + } + GhidraFolder newGhidraParent = (GhidraFolder) newParent; return getFileData().copyTo(newGhidraParent.getFolderData(), monitor != null ? monitor : TaskMonitor.DUMMY); } + @Override + public DomainFile copyToAsLink(DomainFolder newParent) throws IOException { + if (!GhidraFolder.class.isAssignableFrom(newParent.getClass())) { + throw new UnsupportedOperationException("newParent does not support copyToAsLink"); + } + GhidraFolder newGhidraParent = (GhidraFolder) newParent; + return getFileData().copyToAsLink(newGhidraParent.getFolderData()); + } + @Override public DomainFile copyVersionTo(int version, DomainFolder destFolder, TaskMonitor monitor) throws IOException, CancelledException { - GhidraFolder destGhidraFolder = (GhidraFolder) destFolder; // assumes single implementation + if (!GhidraFolder.class.isAssignableFrom(destFolder.getClass())) { + throw new UnsupportedOperationException("destFolder does not support copyVersionTo"); + } + GhidraFolder destGhidraFolder = (GhidraFolder) destFolder; return getFileData().copyVersionTo(version, destGhidraFolder.getFolderData(), monitor != null ? monitor : TaskMonitor.DUMMY); } @@ -507,7 +556,7 @@ public class GhidraFile implements DomainFile { * only when a non shared project is being converted to a shared project. * @param monitor task monitor * @throws IOException if an IO error occurs - * @throws CancelledException if task cancelled + * @throws CancelledException if task is cancelled */ void convertToPrivateFile(TaskMonitor monitor) throws IOException, CancelledException { getFileData().convertToPrivateFile( @@ -596,6 +645,10 @@ public class GhidraFile implements DomainFile { @Override public String toString() { + ProjectLocator projectLocator = parent.getProjectData().getProjectLocator(); + if (projectLocator.isTransient()) { + return fileManager.getProjectLocator().getName() + getPathname(); + } return fileManager.getProjectLocator().getName() + ":" + getPathname(); } 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 a0b0c7413c..e0a09f6be6 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 @@ -17,6 +17,8 @@ package ghidra.framework.data; import java.awt.*; import java.io.*; +import java.net.MalformedURLException; +import java.net.URL; import java.util.HashMap; import java.util.Map; @@ -27,9 +29,9 @@ import db.Field; import db.buffers.*; import generic.theme.GColor; import generic.theme.GIcon; -import ghidra.framework.client.ClientUtil; -import ghidra.framework.client.NotConnectedException; +import ghidra.framework.client.*; import ghidra.framework.model.*; +import ghidra.framework.protocol.ghidra.GhidraURL; import ghidra.framework.store.*; import ghidra.framework.store.FileSystem; import ghidra.framework.store.local.LocalFileSystem; @@ -37,12 +39,14 @@ import ghidra.framework.store.local.LocalFolderItem; import ghidra.util.*; import ghidra.util.exception.*; import ghidra.util.task.TaskMonitor; -import ghidra.util.task.TaskMonitorAdapter; import resources.MultiIcon; import resources.icons.TranslateIcon; public class GhidraFileData { + static final int ICON_WIDTH = 18; + static final int ICON_HEIGHT = 17; + private static final boolean ALWAYS_MERGE = System.getProperty("ForceMerge") != null; //@formatter:off @@ -50,6 +54,7 @@ public class GhidraFileData { public static final Icon CHECKED_OUT_ICON = new GIcon("icon.project.data.file.ghidra.checked.out"); public static final Icon CHECKED_OUT_EXCLUSIVE_ICON = new GIcon("icon.project.data.file.ghidra.checked.out.exclusive"); public static final Icon HIJACKED_ICON = new GIcon("icon.project.data.file.ghidra.hijacked"); + public static final Icon VERSION_ICON = new VersionIcon(); public static final Icon READ_ONLY_ICON = new GIcon("icon.project.data.file.ghidra.read.only"); public static final Icon NOT_LATEST_CHECKED_OUT_ICON = new GIcon("icon.project.data.file.ghidra.not.latest"); @@ -202,9 +207,32 @@ public class GhidraFileData { return new GhidraFile(parent.getDomainFolder(), name); } + /** + * Get a remote Ghidra URL for this domain file if available within a remote repository. + * @return remote Ghidra URL for this file or null + */ + URL getSharedProjectURL() { + synchronized (fileSystem) { + RepositoryAdapter repository = parent.getProjectFileManager().getRepository(); + if (versionedFolderItem != null && repository != null) { + URL folderURL = parent.getDomainFolder().getSharedProjectURL(); + try { + // Direct URL construction done so that ghidra protocol + // extension may be supported + return new URL(folderURL.toExternalForm() + name); + } + catch (MalformedURLException e) { + // ignore + } + } + return null; + } + } + /** * Reassign a new file-ID to resolve file-ID conflict. * Conflicts can occur as a result of a cancelled check-out. + * @throws IOException if an IO error occurs */ void resetFileID() throws IOException { synchronized (fileSystem) { @@ -262,6 +290,8 @@ public class GhidraFileData { String getContentType() { synchronized (fileSystem) { FolderItem item = folderItem != null ? folderItem : versionedFolderItem; + // 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; } @@ -270,14 +300,27 @@ public class GhidraFileData { } } - Class getDomainObjectClass() { + /** + * Get content handler + * @return content handler + * @throws IOException if an IO error occurs, file not found, or unsupported content + */ + ContentHandler getContentHandler() throws IOException { synchronized (fileSystem) { FolderItem item = folderItem != null ? folderItem : versionedFolderItem; + // this can happen when we are trying to load a version file from + // a server to which we are not connected + if (item == null) { + throw new FileNotFoundException(name + " not found"); + } + return DomainObjectAdapter.getContentHandler(item.getContentType()); + } + } + + Class getDomainObjectClass() { + synchronized (fileSystem) { try { - ContentHandler ch = DomainObjectAdapter.getContentHandler(item.getContentType()); - if (ch != null) { - return ch.getDomainObjectClass(); - } + return getContentHandler().getDomainObjectClass(); } catch (IOException e) { // ignore missing content handler @@ -289,8 +332,7 @@ public class GhidraFileData { ChangeSet getChangesByOthersSinceCheckout() throws VersionException, IOException { synchronized (fileSystem) { if (versionedFolderItem != null && folderItem != null && folderItem.isCheckedOut()) { - ContentHandler ch = - DomainObjectAdapter.getContentHandler(folderItem.getContentType()); + ContentHandler ch = getContentHandler(); return ch.getChangeSet(versionedFolderItem, folderItem.getCheckoutVersion(), versionedFolderItem.getCurrentVersion()); } @@ -305,10 +347,9 @@ public class GhidraFileData { DomainObject getDomainObject(Object consumer, boolean okToUpgrade, boolean okToRecover, TaskMonitor monitor) throws VersionException, IOException, CancelledException { FolderItem myFolderItem; - ContentHandler ch; DomainObjectAdapter domainObj = null; synchronized (fileSystem) { - if (fileSystem.isReadOnly()) { + if (fileSystem.isReadOnly() || isLinkFile()) { return getReadOnlyDomainObject(consumer, DomainFile.DEFAULT_VERSION, monitor); } domainObj = getOpenedDomainObject(); @@ -321,8 +362,8 @@ public class GhidraFileData { return domainObj; } } + ContentHandler ch = getContentHandler(); if (folderItem == null) { - ch = DomainObjectAdapter.getContentHandler(versionedFolderItem.getContentType()); DomainObjectAdapter doa = ch.getReadOnlyObject(versionedFolderItem, DomainFile.DEFAULT_VERSION, true, consumer, monitor); doa.setChanged(false); @@ -331,7 +372,6 @@ public class GhidraFileData { proxy.setLastModified(getLastModifiedTime()); return doa; } - ch = DomainObjectAdapter.getContentHandler(folderItem.getContentType()); myFolderItem = folderItem; domainObj = ch.getDomainObject(myFolderItem, parent.getUserFileSystem(), @@ -368,14 +408,7 @@ public class GhidraFileData { FolderItem item = (folderItem != null && version == DomainFile.DEFAULT_VERSION) ? folderItem : versionedFolderItem; - - // 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 null; - } - - ContentHandler ch = DomainObjectAdapter.getContentHandler(item.getContentType()); + ContentHandler ch = getContentHandler(); DomainObjectAdapter doa = ch.getReadOnlyObject(item, version, true, consumer, monitor); doa.setChanged(false); @@ -390,15 +423,12 @@ public class GhidraFileData { throws VersionException, IOException, CancelledException { synchronized (fileSystem) { DomainObjectAdapter obj = null; + ContentHandler ch = getContentHandler(); if (versionedFolderItem == null || (version == DomainFile.DEFAULT_VERSION && folderItem != null) || isHijacked()) { - ContentHandler ch = - DomainObjectAdapter.getContentHandler(folderItem.getContentType()); obj = ch.getImmutableObject(folderItem, consumer, version, -1, monitor); } else { - ContentHandler ch = - DomainObjectAdapter.getContentHandler(versionedFolderItem.getContentType()); obj = ch.getImmutableObject(versionedFolderItem, consumer, version, -1, monitor); } DomainFileProxy proxy = new DomainFileProxy(name, getParent().getPathname(), obj, @@ -419,8 +449,11 @@ public class GhidraFileData { } boolean takeRecoverySnapshot() throws IOException { + if (fileSystem.isReadOnly()) { + return true; + } DomainObjectAdapter dobj = fileManager.getOpenedDomainObject(getPathname()); - if (fileSystem.isReadOnly() || !(dobj instanceof DomainObjectAdapterDB) || + if (!(dobj instanceof DomainObjectAdapterDB) || !dobj.isChanged()) { return true; } @@ -481,13 +514,19 @@ public class GhidraFileData { private Icon generateIcon(boolean disabled) { if (parent == null) { // instance has been disposed - return UNSUPPORTED_FILE_ICON; + return DomainFile.UNSUPPORTED_FILE_ICON; } synchronized (fileSystem) { + + boolean isLink = isLinkFile(); + FolderItem item = folderItem != null ? folderItem : versionedFolderItem; + + Icon baseIcon = new TranslateIcon(getBaseIcon(item), 1, 1); + if (versionedFolderItem != null) { MultiIcon multiIcon = new MultiIcon(VERSION_ICON, disabled); - multiIcon.addIcon(getBaseIcon(item)); + multiIcon.addIcon(baseIcon); if (isHijacked()) { multiIcon.addIcon(HIJACKED_ICON); } @@ -504,12 +543,15 @@ public class GhidraFileData { } } } + if (isLink) { + multiIcon.addIcon(new TranslateIcon(LinkHandler.LINK_ICON, 0, 1)); + } return multiIcon; } else if (folderItem != null) { - MultiIcon multiIcon = new MultiIcon(getBaseIcon(item), disabled); + MultiIcon multiIcon = new MultiIcon(baseIcon, disabled, ICON_WIDTH, ICON_HEIGHT); if (isReadOnly() && !fileSystem.isReadOnly()) { - multiIcon.addIcon(new TranslateIcon(READ_ONLY_ICON, 6, 6)); + multiIcon.addIcon(new TranslateIcon(READ_ONLY_ICON, 8, 9)); } if (isCheckedOut()) { if (isCheckedOutExclusive()) { @@ -519,23 +561,23 @@ public class GhidraFileData { multiIcon.addIcon(CHECKED_OUT_ICON); } } + if (isLink) { + multiIcon.addIcon(new TranslateIcon(LinkHandler.LINK_ICON, 0, 1)); + } return multiIcon; } } - return UNSUPPORTED_FILE_ICON; + return DomainFile.UNSUPPORTED_FILE_ICON; } private Icon getBaseIcon(FolderItem item) { try { - ContentHandler ch = DomainObjectAdapter.getContentHandler(item.getContentType()); - if (ch != null) { - return ch.getIcon(); - } + return getContentHandler().getIcon(); } catch (IOException e) { // ignore missing content handler } - return UNSUPPORTED_FILE_ICON; + return DomainFile.UNSUPPORTED_FILE_ICON; } boolean isChanged() { @@ -593,9 +635,19 @@ public class GhidraFileData { boolean canAddToRepository() { synchronized (fileSystem) { try { - return (!fileSystem.isReadOnly() && !versionedFileSystem.isReadOnly() && - folderItem != null && versionedFolderItem == null && - !folderItem.isCheckedOut() && isVersionControlSupported()); + if (fileSystem.isReadOnly() || versionedFileSystem.isReadOnly()) { + return false; + } + if (folderItem == null || versionedFolderItem != null) { + return false; + } + if (folderItem.isCheckedOut()) { + return false; + } + if (isLinkFile()) { + return GhidraURL.isServerRepositoryURL(LinkHandler.getURL(folderItem)); + } + return !getContentHandler().isPrivateContentType(); } catch (IOException e) { return false; @@ -606,8 +658,11 @@ public class GhidraFileData { boolean canCheckout() { synchronized (fileSystem) { try { - return folderItem == null && !fileSystem.isReadOnly() && - !versionedFileSystem.isReadOnly(); + if (folderItem != null || fileSystem.isReadOnly() || + versionedFileSystem.isReadOnly()) { + return false; + } + return !isLinkFile(); } catch (IOException e) { return false; @@ -627,26 +682,6 @@ public class GhidraFileData { } } - boolean isVersionControlSupported() { - synchronized (fileSystem) { - if (versionedFolderItem != null) { - return true; - } - if (!(folderItem instanceof DatabaseItem)) { - return false; - } - try { - ContentHandler ch = - DomainObjectAdapter.getContentHandler(folderItem.getContentType()); - return !ch.isPrivateContentType(); - } - catch (IOException e) { - // ignore missing content handler - } - return false; - } - } - int getVersion() { synchronized (fileSystem) { try { @@ -714,18 +749,15 @@ public class GhidraFileData { throws IOException, CancelledException { DomainObjectAdapter oldDomainObj = null; synchronized (fileSystem) { - if (!isVersionControlSupported()) { - throw new AssertException("file type does supported version control"); + if (!canAddToRepository()) { + if (fileSystem.isReadOnly() || versionedFileSystem.isReadOnly()) { + throw new ReadOnlyException( + "addToVersionControl permitted within writeable project and repository only"); + } + throw new IOException("addToVersionControl not allowed for file"); } - if (versionedFolderItem != null) { - throw new AssertException("file already versioned"); - } - if (!versionedFileSystem.isOnline()) { - throw new NotConnectedException("Not connected to repository server"); - } - if (fileSystem.isReadOnly() || versionedFileSystem.isReadOnly()) { - throw new ReadOnlyException( - "addToVersionControl permitted within writeable project and repository only"); + if (isLinkFile()) { + keepCheckedOut = false; } String parentPath = parent.getPathname(); String user = ClientUtil.getUserName(); @@ -832,6 +864,9 @@ public class GhidraFileData { if (!versionedFileSystem.isOnline()) { throw new NotConnectedException("Not connected to repository server"); } + if (isLinkFile()) { + return false; + } String user = ClientUtil.getUserName(); ProjectLocator projectLocator = parent.getProjectLocator(); CheckoutType checkoutType; @@ -934,8 +969,7 @@ public class GhidraFileData { if (checkinHandler.createKeepFile()) { DomainObject sourceObj = null; try { - ContentHandler ch = - DomainObjectAdapter.getContentHandler(folderItem.getContentType()); + ContentHandler ch = getContentHandler(); sourceObj = ch.getImmutableObject(folderItem, this, DomainFile.DEFAULT_VERSION, -1, monitor); createKeepFile(sourceObj, monitor); @@ -968,18 +1002,18 @@ public class GhidraFileData { /** * Verify that current user is the checkout user for this file - * @param caseName name of user case (e.g., checkin) - * @return true if server/repository will permit current user to checkin, - * or update checkout version of current file. (i.e., server login matches + * @param operationName name of user case (e.g., checkin) + * @throws IOException if server/repository will not permit current user to checkin, + * or update checkout version of current file. (i.e., server login does not match * user name used at time of initial checkout) */ - private void verifyRepoUser(String caseName) throws IOException { + private void verifyRepoUser(String operationName) throws IOException { if (versionedFileSystem instanceof LocalFileSystem) { return; // rely on local project ownership } String repoUserName = versionedFileSystem.getUserName(); if (repoUserName == null) { - throw new IOException("File " + caseName + " not permitted (not connected)"); + throw new IOException("File " + operationName + " not permitted (not connected)"); } ItemCheckoutStatus checkoutStatus = getCheckoutStatus(); if (checkoutStatus == null) { @@ -987,7 +1021,7 @@ public class GhidraFileData { } String checkoutUserName = checkoutStatus.getUser(); if (!repoUserName.equals(checkoutUserName)) { - throw new IOException("File " + caseName + " not permitted - checkout user '" + + throw new IOException("File " + operationName + " not permitted - checkout user '" + checkoutUserName + "' differs from repository user '" + repoUserName + "'"); } } @@ -1016,7 +1050,7 @@ public class GhidraFileData { } verifyRepoUser("checkin"); if (monitor == null) { - monitor = TaskMonitorAdapter.DUMMY_MONITOR; + monitor = TaskMonitor.DUMMY; } synchronized (fileSystem) { if (busy) { @@ -1036,9 +1070,7 @@ public class GhidraFileData { Msg.info(this, "Checkin with merge for " + name); - ContentHandler ch = - DomainObjectAdapter.getContentHandler(folderItem.getContentType()); - + ContentHandler ch = getContentHandler(); DomainObjectAdapter checkinObj = ch.getDomainObject(versionedFolderItem, null, folderItem.getCheckoutId(), okToUpgrade, false, this, monitor); checkinObj.setDomainFile(new DomainFileProxy(name, getParent().getPathname(), @@ -1366,9 +1398,12 @@ public class GhidraFileData { private void removeAssociatedUserDataFile() { try { - FolderItem item = folderItem != null ? folderItem : versionedFolderItem; - ContentHandler ch = DomainObjectAdapter.getContentHandler(item.getContentType()); - ch.removeUserDataFile(item, parent.getUserFileSystem()); + ContentHandler ch = getContentHandler(); + if (ch instanceof DBWithUserDataContentHandler) { + FolderItem item = folderItem != null ? folderItem : versionedFolderItem; + ((DBWithUserDataContentHandler) ch).removeUserDataFile(item, + parent.getUserFileSystem()); + } } catch (Exception e) { // ignore missing content handler @@ -1403,7 +1438,7 @@ public class GhidraFileData { } verifyRepoUser("merge"); if (monitor == null) { - monitor = TaskMonitorAdapter.DUMMY_MONITOR; + monitor = TaskMonitor.DUMMY; } synchronized (fileSystem) { if (busy) { @@ -1425,8 +1460,7 @@ public class GhidraFileData { "Merge failed, file merge is not supported in headless mode"); } - ContentHandler ch = - DomainObjectAdapter.getContentHandler(folderItem.getContentType()); + ContentHandler ch = getContentHandler(); // Test versioned file for VersionException int mergeVer = versionedFolderItem.getCurrentVersion(); @@ -1556,7 +1590,7 @@ public class GhidraFileData { checkInUse(); GhidraFolderData oldParent = parent; String oldName = name; - String newName = getTargetName(name, newParent); + String newName = newParent.getTargetName(name); try { if (isHijacked()) { fileSystem.moveItem(parent.getPathname(), name, newParent.getPathname(), @@ -1595,15 +1629,49 @@ public class GhidraFileData { } } - private String getTargetName(String preferredName, GhidraFolderData newParent) - throws IOException { - String newName = preferredName; - int i = 1; - while (newParent.getFileData(newName, false) != null) { - newName = preferredName + "." + i; - i++; + boolean isLinkFile() { + synchronized (fileSystem) { + try { + return LinkHandler.class.isAssignableFrom(getContentHandler().getClass()); + } + catch (IOException e) { + return false; + } + } + } + + /** + * Get URL associated with a link-file + * @return link-file URL or null if not a link-file + * @throws IOException if an IO error occurs + */ + URL getLinkFileURL() throws IOException { + if (!isLinkFile()) { + return null; + } + FolderItem item = folderItem != null ? folderItem : versionedFolderItem; + return LinkHandler.getURL(item); + } + + public boolean isLinkingSupported() { + synchronized (fileSystem) { + try { + return getContentHandler().getLinkHandler() != null; + } + catch (IOException e) { + return false; // ignore error + } + } + } + + public DomainFile copyToAsLink(GhidraFolderData newParentData) throws IOException { + synchronized (fileSystem) { + LinkHandler lh = getContentHandler().getLinkHandler(); + if (lh == null) { + return null; + } + return newParentData.copyAsLink(fileManager, getPathname(), name, lh); } - return newName; } GhidraFile copyTo(GhidraFolderData newParentData, TaskMonitor monitor) @@ -1615,7 +1683,7 @@ public class GhidraFileData { FolderItem item = folderItem != null ? folderItem : versionedFolderItem; String pathname = newParentData.getPathname(); String contentType = item.getContentType(); - String targetName = getTargetName(name, newParentData); + String targetName = newParentData.getTargetName(name); String user = ClientUtil.getUserName(); try { if (item instanceof DatabaseItem) { @@ -1668,7 +1736,7 @@ public class GhidraFileData { } String pathname = destFolderData.getPathname(); String contentType = versionedFolderItem.getContentType(); - String targetName = getTargetName(name + "_v" + version, destFolderData); + String targetName = destFolderData.getTargetName(name + "_v" + version); String user = ClientUtil.getUserName(); try { BufferFile bufferFile = ((DatabaseItem) versionedFolderItem).open(version); @@ -1697,7 +1765,9 @@ public class GhidraFileData { /** * Copy this file to make a private file if it is versioned. This method should be called * only when a non shared project is being converted to a shared project. - * @throws IOException + * @param monitor task monitor + * @throws IOException if an IO error occurs + * @throws CancelledException if task is cancelled */ void convertToPrivateFile(TaskMonitor monitor) throws IOException, CancelledException { synchronized (fileSystem) { @@ -1749,7 +1819,10 @@ public class GhidraFileData { Map getMetadata() { FolderItem item = (folderItem != null) ? folderItem : versionedFolderItem; + return getMetadata(item); + } + static Map getMetadata(FolderItem item) { GenericDomainObjectDB genericDomainObj = null; try { if (item instanceof DatabaseItem) { @@ -1767,7 +1840,7 @@ public class GhidraFileData { // file created with newer version of Ghidra } catch (IOException e) { - Msg.error(this, "Read meta-data error", e); + Msg.error(GhidraFileData.class, "Read meta-data error", e); } finally { if (genericDomainObj != null) { @@ -1782,13 +1855,17 @@ public class GhidraFileData { if (fileManager == null) { return name + "(disposed)"; } + ProjectLocator projectLocator = fileManager.getProjectLocator(); + if (projectLocator.isTransient()) { + return fileManager.getProjectLocator().getName() + getPathname(); + } return fileManager.getProjectLocator().getName() + ":" + getPathname(); } - private class GenericDomainObjectDB extends DomainObjectAdapterDB { + private static class GenericDomainObjectDB extends DomainObjectAdapterDB { protected GenericDomainObjectDB(DBHandle dbh) throws IOException { - super(dbh, "Generic", 500, GhidraFileData.this); + super(dbh, "Generic", 500, dbh); loadMetadata(); } @@ -1803,7 +1880,7 @@ public class GhidraFileData { } public void release() { - release(GhidraFileData.this); + release(dbh); } } @@ -1816,8 +1893,8 @@ class VersionIcon implements Icon { private static Color VERSION_ICON_COLOR_LIGHT = new GColor("color.bg.ghidra.file.data.version.icon.light"); - private static final int WIDTH = 18; - private static final int HEIGHT = 17; + private static final int WIDTH = GhidraFileData.ICON_WIDTH; + private static final int HEIGHT = GhidraFileData.ICON_HEIGHT; @Override public int getIconHeight() { diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/GhidraFolder.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/GhidraFolder.java index 12986ee1e3..3a3de7ff1e 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/GhidraFolder.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/GhidraFolder.java @@ -16,16 +16,19 @@ package ghidra.framework.data; import java.io.*; +import java.net.MalformedURLException; +import java.net.URL; import java.util.List; +import ghidra.framework.client.RepositoryAdapter; import ghidra.framework.model.*; +import ghidra.framework.protocol.ghidra.GhidraURL; import ghidra.framework.store.FileSystem; import ghidra.framework.store.local.LocalFileSystem; import ghidra.util.InvalidNameException; import ghidra.util.Msg; import ghidra.util.exception.CancelledException; import ghidra.util.task.TaskMonitor; -import ghidra.util.task.TaskMonitorAdapter; public class GhidraFolder implements DomainFolder { @@ -83,19 +86,6 @@ public class GhidraFolder implements DomainFolder { return fileData; } - GhidraFolderData getFolderPathData(String folderPath) throws FileNotFoundException { - GhidraFolderData parentData = (folderPath.startsWith(FileSystem.SEPARATOR)) - ? fileManager.getRootFolderData() - : getFolderData(); - GhidraFolderData folderData = parentData.getFolderPathData(folderPath, false); - if (folderData == null) { - String path = (folderPath.startsWith(FileSystem.SEPARATOR)) ? folderPath - : getPathname(folderPath); - throw new FileNotFoundException("folder " + path + " not found"); - } - return folderData; - } - GhidraFolderData getFolderData() throws FileNotFoundException { if (parent == null) { return fileManager.getRootFolderData(); @@ -140,10 +130,10 @@ public class GhidraFolder implements DomainFolder { /** * Refresh folder data - used for testing only - * @throws IOException + * @throws IOException if an IO error occurs */ void refreshFolderData() throws IOException { - getFolderData().refresh(false, true, TaskMonitorAdapter.DUMMY_MONITOR); + getFolderData().refresh(false, true, TaskMonitor.DUMMY); } @Override @@ -193,6 +183,40 @@ public class GhidraFolder implements DomainFolder { return path; } + @Override + public URL getSharedProjectURL() { + ProjectLocator projectLocator = getProjectLocator(); + URL projectURL = projectLocator.getURL(); + if (!GhidraURL.isServerRepositoryURL(projectURL)) { + RepositoryAdapter repository = fileManager.getRepository(); + if (repository == null) { + return null; + } + // NOTE: only supports ghidra protocol without extension protocol. + // Assumes any extension protocol use would be reflected in projectLocator URL. + ServerInfo serverInfo = repository.getServerInfo(); + projectURL = GhidraURL.makeURL(serverInfo.getServerName(), serverInfo.getPortNumber(), + repository.getName()); + } + try { + // Direct URL construction done so that ghidra protocol + // extension may be supported + String urlStr = projectURL.toExternalForm(); + if (urlStr.endsWith(FileSystem.SEPARATOR)) { + urlStr = urlStr.substring(0, urlStr.length() - 1); + } + String path = getPathname(); + if (!path.endsWith(FileSystem.SEPARATOR)) { + path += FileSystem.SEPARATOR; + } + urlStr += path; + return new URL(urlStr); + } + catch (MalformedURLException e) { + return null; + } + } + @Override public boolean isInWritableProject() { return !getProjectData().getLocalFileSystem().isReadOnly(); @@ -244,7 +268,9 @@ public class GhidraFolder implements DomainFolder { return folderData.isEmpty(); } catch (FileNotFoundException e) { - return false; // TODO: what should we return if folder not found + // TODO: what should we return if folder not found or error occurs? + // True is returned to allow this method to be used to avoid continued access. + return true; } } } @@ -295,14 +321,14 @@ public class GhidraFolder implements DomainFolder { public DomainFile createFile(String fileName, DomainObject obj, TaskMonitor monitor) throws InvalidNameException, IOException, CancelledException { return createFolderData().createFile(fileName, obj, - monitor != null ? monitor : TaskMonitorAdapter.DUMMY_MONITOR); + monitor != null ? monitor : TaskMonitor.DUMMY); } @Override public DomainFile createFile(String fileName, File packFile, TaskMonitor monitor) throws InvalidNameException, IOException, CancelledException { return createFolderData().createFile(fileName, packFile, - monitor != null ? monitor : TaskMonitorAdapter.DUMMY_MONITOR); + monitor != null ? monitor : TaskMonitor.DUMMY); } @Override @@ -325,8 +351,11 @@ public class GhidraFolder implements DomainFolder { if (parent == null) { throw new UnsupportedOperationException("root folder may not be moved"); } + if (!GhidraFolder.class.isAssignableFrom(newParent.getClass())) { + throw new UnsupportedOperationException("newParent does not support moveTo"); + } GhidraFolderData folderData = getFolderData(); - GhidraFolder newGhidraParent = (GhidraFolder) newParent; // assumes single implementation + GhidraFolder newGhidraParent = (GhidraFolder) newParent; return folderData.moveTo(newGhidraParent.getFolderData()); } @@ -334,9 +363,22 @@ public class GhidraFolder implements DomainFolder { public GhidraFolder copyTo(DomainFolder newParent, TaskMonitor monitor) throws IOException, CancelledException { GhidraFolderData folderData = getFolderData(); - GhidraFolder newGhidraParent = (GhidraFolder) newParent; // assumes single implementation + if (!GhidraFolder.class.isAssignableFrom(newParent.getClass())) { + throw new UnsupportedOperationException("newParent does not support copyTo"); + } + GhidraFolder newGhidraParent = (GhidraFolder) newParent; return folderData.copyTo(newGhidraParent.getFolderData(), - monitor != null ? monitor : TaskMonitorAdapter.DUMMY_MONITOR); + monitor != null ? monitor : TaskMonitor.DUMMY); + } + + @Override + public DomainFile copyToAsLink(DomainFolder newParent) throws IOException { + GhidraFolderData folderData = getFolderData(); + if (!GhidraFolder.class.isAssignableFrom(newParent.getClass())) { + throw new UnsupportedOperationException("newParent does not support copyToAsLink"); + } + GhidraFolder newGhidraParent = (GhidraFolder) newParent; + return folderData.copyToAsLink(newGhidraParent.getFolderData()); } /** 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 609b16dc1a..39e78a57fa 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 @@ -16,10 +16,13 @@ package ghidra.framework.data; import java.io.*; +import java.net.URL; import java.util.*; import ghidra.framework.client.RepositoryAdapter; import ghidra.framework.model.*; +import ghidra.framework.protocol.ghidra.GhidraURL; +import ghidra.framework.protocol.ghidra.TransientProjectData; import ghidra.framework.store.FileSystem; import ghidra.framework.store.local.LocalFileSystem; import ghidra.util.*; @@ -257,9 +260,10 @@ class GhidraFolderData { return folderList.isEmpty() && fileList.isEmpty(); } catch (IOException e) { - // ignore + // TODO: what should we return if folder not found or error occurs? + // True is returned to allow this method to be used to avoid continued access. + return true; } - return false; } List getFileNames() { @@ -919,7 +923,7 @@ class GhidraFolderData { DomainFile oldDf = doa.getDomainFile(); try { - ContentHandler ch = DomainObjectAdapter.getContentHandler(doa); + ContentHandler ch = DomainObjectAdapter.getContentHandler(doa); ch.createFile(fileSystem, null, getPathname(), fileName, obj, monitor); if (oldDf != null) { @@ -1140,6 +1144,88 @@ class GhidraFolderData { } } + DomainFile copyToAsLink(GhidraFolderData newParentData) throws IOException { + synchronized (fileSystem) { + String linkFilename = name; + if (linkFilename == null) { + if (fileManager instanceof TransientProjectData) { + linkFilename = fileManager.getRepository().getName(); + } + else { + linkFilename = fileManager.getProjectLocator().getName(); + } + } + return newParentData.copyAsLink(fileManager, getPathname(), linkFilename, + FolderLinkContentHandler.INSTANCE); + } + } + + DomainFile copyAsLink(ProjectData sourceProjectData, String pathname, String linkFilename, + LinkHandler lh) throws IOException { + synchronized (fileSystem) { + if (fileSystem.isReadOnly()) { + throw new ReadOnlyException("copyAsLink permitted to writeable project only"); + } + + if (sourceProjectData == fileManager) { + // internal linking not yet supported + Msg.error(this, "Internal file/folder links not yet supported"); + return null; + } + + URL ghidraUrl = null; + if (sourceProjectData instanceof TransientProjectData) { + RepositoryAdapter repository = sourceProjectData.getRepository(); + ServerInfo serverInfo = repository.getServerInfo(); + ghidraUrl = + GhidraURL.makeURL(serverInfo.getServerName(), serverInfo.getPortNumber(), + repository.getName(), pathname); + } + else { + ProjectLocator projectLocator = sourceProjectData.getProjectLocator(); + if (projectLocator.equals(fileManager.getProjectLocator())) { + return null; // local internal linking not supported + } + ghidraUrl = GhidraURL.makeURL(projectLocator, pathname, null); + } + + String newName = linkFilename; + int i = 1; + while (true) { + GhidraFileData fileData = getFileData(newName, false); + if (fileData != null) { + // return existing file if link URL matches + if (ghidraUrl.equals(fileData.getLinkFileURL())) { + return getDomainFile(newName); + } + newName = linkFilename + "." + i; + ++i; + } + break; + } + + try { + lh.createLink(ghidraUrl, fileSystem, getPathname(), newName); + } + catch (InvalidNameException e) { + throw new IOException(e); // unexpected + } + + fileChanged(newName); + return getDomainFile(newName); + } + } + + String getTargetName(String preferredName) throws IOException { + String newName = preferredName; + int i = 1; + while (getFileData(newName, false) != null) { + newName = preferredName + "." + i; + i++; + } + return newName; + } + /** * used for testing */ @@ -1156,6 +1242,10 @@ class GhidraFolderData { @Override public String toString() { + ProjectLocator projectLocator = fileManager.getProjectLocator(); + if (projectLocator.isTransient()) { + return fileManager.getProjectLocator().getName() + getPathname(); + } return fileManager.getProjectLocator().getName() + ":" + getPathname(); } 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 new file mode 100644 index 0000000000..f9e65aacd5 --- /dev/null +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkHandler.java @@ -0,0 +1,210 @@ +/* ### + * 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 java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Map; + +import javax.help.UnsupportedOperationException; +import javax.swing.Icon; + +import generic.theme.GIcon; +import ghidra.framework.model.*; +import ghidra.framework.protocol.ghidra.*; +import ghidra.framework.protocol.ghidra.GhidraURLConnection.StatusCode; +import ghidra.framework.store.FileSystem; +import ghidra.framework.store.FolderItem; +import ghidra.framework.store.local.LocalFileSystem; +import ghidra.util.InvalidNameException; +import ghidra.util.exception.*; +import ghidra.util.task.TaskMonitor; + +/** + * NOTE: ALL ContentHandler implementations MUST END IN "ContentHandler". If not, + * the ClassSearcher will not find them. + * + * LinkHandler defines an application interface for handling domain files which are + * shortcut links to another supported content type. + * + * @param {@link URLLinkObject} implementation class + */ +public abstract class LinkHandler extends DBContentHandler { + + // TODO: Need to improve by making this meta data on file instead of database content. + // Metadata use would eliminate need for DB but we lack support for non-DB files. + + public static final String URL_METADATA_KEY = "link.url"; + + // 16x16 link icon where link is placed in lower-left corner + public static final Icon LINK_ICON = new GIcon("icon.content.handler.link.overlay"); + + /** + * Create a link file using the specified URL + * @param ghidraUrl link URL (must be a Ghidra URL - see {@link GhidraURL}). + * @param fs filesystem where link file should be created + * @param folderPath folder path which should contain link file + * @param linkFilename link filename + * @throws IOException if an IO error occurs + * @throws InvalidNameException if invalid folderPath or linkFilename specified + */ + protected final void createLink(URL ghidraUrl, LocalFileSystem fs, String folderPath, + String linkFilename) throws IOException, InvalidNameException { + URLLinkObject link = new URLLinkObject(linkFilename, ghidraUrl, this); + try { + createFile(fs, null, folderPath, linkFilename, link, TaskMonitor.DUMMY); + } + catch (CancelledException e) { + throw new AssertException(e); // won't happen + } + finally { + link.release(this); + } + } + + @SuppressWarnings("unchecked") + @Override + public final T getReadOnlyObject(FolderItem item, int version, boolean okToUpgrade, + Object consumer, TaskMonitor monitor) + throws IOException, VersionException, CancelledException { + + if (!okToUpgrade) { + throw new IllegalArgumentException("okToUpgrade must be true"); + } + + URL url = getURL(item); + + Class domainObjectClass = getDomainObjectClass(); + if (domainObjectClass == null) { + throw new UnsupportedOperationException(""); + } + + GhidraURLWrappedContent wrappedContent = null; + Object content = null; + try { + GhidraURLConnection c = (GhidraURLConnection) url.openConnection(); + Object obj = c.getContent(); // read-only access + if (c.getStatusCode() == StatusCode.UNAUTHORIZED) { + throw new IOException("Authorization failure"); + } + if (!(obj instanceof GhidraURLWrappedContent)) { + throw new IOException("Unsupported linked content"); + } + wrappedContent = (GhidraURLWrappedContent) obj; + content = wrappedContent.getContent(consumer); + if (!(content instanceof DomainFile)) { + throw new IOException("Unsupported linked content: " + content.getClass()); + } + DomainFile linkedFile = (DomainFile) content; + if (!getDomainObjectClass().isAssignableFrom(linkedFile.getDomainObjectClass())) { + throw new BadLinkException( + "Excepted " + getDomainObjectClass() + " but linked to " + + linkedFile.getDomainObjectClass()); + } + return (T) linkedFile.getReadOnlyDomainObject(consumer, version, monitor); + } + finally { + if (content != null) { + wrappedContent.release(content, consumer); + } + } + } + + @Override + public final T getDomainObject(FolderItem item, FileSystem userfs, long checkoutId, + boolean okToUpgrade, boolean okToRecover, Object consumer, TaskMonitor monitor) + throws IOException, CancelledException, VersionException { + // Always upgrade if needed for read-only object + return getReadOnlyObject(item, DomainFile.DEFAULT_VERSION, true, consumer, monitor); + } + + @Override + public T getImmutableObject(FolderItem item, Object consumer, int version, int minChangeVersion, + TaskMonitor monitor) throws IOException, CancelledException, VersionException { + throw new UnsupportedOperationException("link-file does not support getImmutableObject"); + } + + @Override + public final ChangeSet getChangeSet(FolderItem versionedFolderItem, int olderVersion, + int newerVersion) throws VersionException, IOException { + return null; + } + + @Override + public final DomainObjectMergeManager getMergeManager(DomainObject resultsObj, + DomainObject sourceObj, + DomainObject originalObj, DomainObject latestObj) { + return null; + } + + @Override + public final boolean isPrivateContentType() { + // NOTE: URL must be checked - only repository-based links may be versioned + return true; + } + + /** + * Get the link URL which corresponds to the specified link file. + * See {@link DomainFile#isLinkFile()}. + * @param linkFile link-file domain file + * @return link URL + * @throws MalformedURLException if link is bad or unsupported. + * @throws IOException if IO error or supported link file not specified + */ + public static URL getURL(DomainFile linkFile) throws IOException { + String contentType = linkFile.getContentType(); + ContentHandler ch = DomainObjectAdapter.getContentHandler(contentType); + if (ch instanceof LinkHandler) { + Map metadata = linkFile.getMetadata(); + String urlStr = metadata.get(URL_METADATA_KEY); + if (urlStr != null) { + return new URL(urlStr); + } + } + throw new IOException("Invalid link file: " + contentType); + } + + /** + * Get the link URL which corresponds to the specified link file. + * See {@link DomainFile#isLinkFile()}. + * @param linkFile link-file folder item + * @return link URL + * @throws MalformedURLException if link is bad or unsupported. + * @throws IOException if IO error or supported link file not specified + */ + static URL getURL(FolderItem linkFile) throws IOException { + + String contentType = linkFile.getContentType(); + ContentHandler ch = DomainObjectAdapter.getContentHandler(contentType); + if (ch instanceof LinkHandler) { + Map metadata = GhidraFileData.getMetadata(linkFile); + String urlStr = metadata.get(URL_METADATA_KEY); + if (urlStr != null) { + return new URL(urlStr); + } + } + throw new IOException("Invalid link file: " + contentType); + } + + /** + * Get the base icon for this link-file which does not include the + * link overlay icon. + */ + @Override + abstract public Icon getIcon(); + +} 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 new file mode 100644 index 0000000000..848f89c494 --- /dev/null +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkedGhidraFile.java @@ -0,0 +1,426 @@ +/* ### + * 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 java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.List; +import java.util.Map; + +import javax.help.UnsupportedOperationException; +import javax.swing.Icon; + +import ghidra.framework.model.*; +import ghidra.framework.protocol.ghidra.GhidraURL; +import ghidra.framework.store.*; +import ghidra.util.*; +import ghidra.util.exception.CancelledException; +import ghidra.util.exception.VersionException; +import ghidra.util.task.TaskMonitor; + +/** + * {@code LinkedGhidraFile} corresponds to a {@link DomainFile} contained within a + * {@link LinkedGhidraFolder}. + */ +class LinkedGhidraFile implements LinkedDomainFile { + + private final LinkedGhidraSubFolder parent; + private final String fileName; + + LinkedGhidraFile(LinkedGhidraSubFolder parent, String fileName) { + this.parent = parent; + this.fileName = fileName; + } + + @Override + public DomainFile getLinkedFile() throws IOException { + return parent.getLinkedFile(fileName); + } + + private DomainFile getLinkedFileNoError() { + return parent.getLinkedFileNoError(fileName); + } + + @Override + public DomainFolder getParent() { + return parent; + } + + @Override + public String getName() { + return fileName; + } + + @Override + public int compareTo(DomainFile df) { + return fileName.compareToIgnoreCase(df.getName()); + } + + @Override + public boolean exists() { + return getLinkedFileNoError() != null; + } + + @Override + public String getFileID() { + DomainFile df = getLinkedFileNoError(); + return df != null ? df.getFileID() : null; + } + + @Override + public DomainFile setName(String newName) throws InvalidNameException, IOException { + throw new ReadOnlyException("linked file is read only"); + } + + @Override + public String getPathname() { + // pathname within project containing folder-link + // getParent() may return a non-linked folder + String path = getParent().getPathname(); + if (path.length() != FileSystem.SEPARATOR.length()) { + path += FileSystem.SEPARATOR; + } + path += fileName; + return path; + } + + @Override + public URL getSharedProjectURL() { + URL folderURL = parent.getSharedProjectURL(); + if (GhidraURL.isServerRepositoryURL(folderURL)) { + // Direct URL construction done so that ghidra protocol + // extension may be supported + try { + return new URL(folderURL.toExternalForm() + fileName); + } + catch (MalformedURLException e) { + // ignore + } + } + return null; + } + + @Override + public ProjectLocator getProjectLocator() { + return parent.getProjectLocator(); + } + + @Override + public String getContentType() { + DomainFile df = getLinkedFileNoError(); + return df != null ? df.getContentType() : ContentHandler.UNKNOWN_CONTENT; + } + + @Override + public Class getDomainObjectClass() { + DomainFile df = getLinkedFileNoError(); + return df != null ? df.getDomainObjectClass() : DomainObject.class; + } + + @Override + public ChangeSet getChangesByOthersSinceCheckout() throws VersionException, IOException { + return null; + } + + @Override + public DomainObject getDomainObject(Object consumer, boolean okToUpgrade, boolean okToRecover, + TaskMonitor monitor) throws VersionException, IOException, CancelledException { + return getReadOnlyDomainObject(consumer, DomainFile.DEFAULT_VERSION, monitor); + } + + @Override + public DomainObject getOpenedDomainObject(Object consumer) { + return null; + } + + @Override + public DomainObject getReadOnlyDomainObject(Object consumer, int version, TaskMonitor monitor) + throws VersionException, IOException, CancelledException { + return getLinkedFile().getReadOnlyDomainObject(consumer, version, monitor); + } + + @Override + public DomainObject getImmutableDomainObject(Object consumer, int version, TaskMonitor monitor) + throws VersionException, IOException, CancelledException { + return getLinkedFile().getImmutableDomainObject(consumer, version, monitor); + } + + @Override + public void save(TaskMonitor monitor) throws IOException, CancelledException { + throw new UnsupportedOperationException(); + } + + @Override + public boolean canSave() { + return false; + } + + @Override + public boolean canRecover() { + return false; + } + + @Override + public boolean takeRecoverySnapshot() throws IOException { + return true; + } + + @Override + public boolean isInWritableProject() { + return false; // While project may be writeable this folder/file is not + } + + @Override + public long getLastModifiedTime() { + DomainFile df = getLinkedFileNoError(); + return df != null ? df.getLastModifiedTime() : 0; + } + + @Override + public Icon getIcon(boolean disabled) { + DomainFile df = getLinkedFileNoError(); + return df != null ? df.getIcon(disabled) : UNSUPPORTED_FILE_ICON; + } + + @Override + public boolean isCheckedOut() { + return false; + } + + @Override + public boolean isCheckedOutExclusive() { + return false; + } + + @Override + public boolean modifiedSinceCheckout() { + return false; + } + + @Override + public boolean canCheckout() { + return false; + } + + @Override + public boolean canCheckin() { + return false; + } + + @Override + public boolean canMerge() { + return false; + } + + @Override + public boolean canAddToRepository() { + return false; + } + + @Override + public void setReadOnly(boolean state) throws IOException { + // ignore + } + + @Override + public boolean isReadOnly() { + return true; // not reflected by icon + } + + @Override + public boolean isVersioned() { + DomainFile df = getLinkedFileNoError(); + return df != null ? df.isVersioned() : false; + } + + @Override + public boolean isHijacked() { + return false; + } + + @Override + public int getLatestVersion() { + DomainFile df = getLinkedFileNoError(); + return df != null ? df.getLatestVersion() : DomainFile.DEFAULT_VERSION; + } + + @Override + public boolean isLatestVersion() { + return true; + } + + @Override + public int getVersion() { + // TODO: Do we want to reveal linked-local-project checkout details? + return getLatestVersion(); + } + + @Override + public Version[] getVersionHistory() throws IOException { + DomainFile df = getLinkedFileNoError(); + return df != null ? df.getVersionHistory() : new Version[0]; + } + + @Override + public void addToVersionControl(String comment, boolean keepCheckedOut, TaskMonitor monitor) + throws IOException, CancelledException { + throw new UnsupportedOperationException(); + } + + @Override + public boolean checkout(boolean exclusive, TaskMonitor monitor) + throws IOException, CancelledException { + throw new UnsupportedOperationException(); + } + + @Override + public void checkin(CheckinHandler checkinHandler, boolean okToUpgrade, TaskMonitor monitor) + throws IOException, VersionException, CancelledException { + throw new UnsupportedOperationException(); + } + + @Override + public void merge(boolean okToUpgrade, TaskMonitor monitor) + throws IOException, VersionException, CancelledException { + throw new UnsupportedOperationException(); + } + + @Override + public void undoCheckout(boolean keep) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public void undoCheckout(boolean keep, boolean force) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public void terminateCheckout(long checkoutId) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public ItemCheckoutStatus[] getCheckouts() throws IOException { + DomainFile df = getLinkedFileNoError(); + return df != null ? df.getCheckouts() : new ItemCheckoutStatus[0]; + } + + @Override + public ItemCheckoutStatus getCheckoutStatus() throws IOException { + // TODO: Do we want to reveal linked-local-project checkout details? + return null; + } + + @Override + public void delete() throws IOException { + throw new ReadOnlyException("linked file is read only"); + } + + @Override + public void delete(int version) throws IOException { + throw new ReadOnlyException("linked file is read only"); + } + + @Override + public DomainFile moveTo(DomainFolder newParent) throws IOException { + throw new ReadOnlyException("linked file is read only"); + } + + @Override + public DomainFile copyTo(DomainFolder newParent, TaskMonitor monitor) + throws IOException, CancelledException { + return getLinkedFile().copyTo(newParent, monitor); + } + + @Override + public DomainFile copyVersionTo(int version, DomainFolder destFolder, TaskMonitor monitor) + throws IOException, CancelledException { + return getLinkedFile().copyVersionTo(version, destFolder, monitor); + } + + @Override + public DomainFile copyToAsLink(DomainFolder newParent) throws IOException { + return getLinkedFile().copyToAsLink(newParent); + } + + @Override + public boolean isLinkingSupported() { + DomainFile df = getLinkedFileNoError(); + return df != null ? df.isLinkingSupported() : false; + } + + @Override + public List getConsumers() { + return List.of(); + } + + @Override + public boolean isChanged() { + return false; + } + + @Override + public boolean isOpen() { + return false; // domain file proxy always used + } + + @Override + public boolean isBusy() { + return false; // domain file proxy always used + } + + @Override + public void packFile(File file, TaskMonitor monitor) throws IOException, CancelledException { + getLinkedFile().packFile(file, monitor); + } + + @Override + public Map getMetadata() { + DomainFile df = getLinkedFileNoError(); + return df != null ? df.getMetadata() : Map.of(); + } + + @Override + public long length() throws IOException { + DomainFile df = getLinkedFileNoError(); + return df != null ? df.length() : 0; + } + + @Override + public boolean isLinkFile() { + DomainFile df = getLinkedFileNoError(); + return df != null ? df.isLinkFile() : false; + } + + @Override + public DomainFolder followLink() { + try { + return FolderLinkContentHandler.getReadOnlyLinkedFolder(this); + } + catch (IOException e) { + Msg.error(this, "Failed to following folder-link: " + getPathname()); + } + return null; + } + + @Override + public String toString() { + return "LinkedGhidraFile: " + 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 new file mode 100644 index 0000000000..6193d4864b --- /dev/null +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkedGhidraFolder.java @@ -0,0 +1,138 @@ +/* ### + * 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 java.io.FileNotFoundException; +import java.io.IOException; +import java.net.URL; + +import javax.swing.Icon; + +import generic.theme.GIcon; +import ghidra.framework.model.*; +import ghidra.framework.protocol.ghidra.GhidraURL; +import ghidra.framework.store.FileSystem; + +/** + * {@code LinkedGhidraFolder} provides the base {@link LinkedDomainFolder} implementation which + * corresponds to a project folder-link (see {@link FolderLinkContentHandler}). + */ +public class LinkedGhidraFolder extends LinkedGhidraSubFolder { + + public static Icon FOLDER_LINK_CLOSED_ICON = + new GIcon("icon.content.handler.linked.folder.closed"); + public static Icon FOLDER_LINK_OPEN_ICON = + new GIcon("icon.content.handler.linked.folder.open"); + + private final Project activeProject; + private final DomainFolder localParent; + private final URL folderUrl; + + private String linkedPathname; + + private URL projectUrl; + + /** + * Construct a linked-folder. + * @param activeProject active project responsible for linked project life-cycle management. + * @param localParent local domain folder which contains folder-link or corresponds directly to + * folder-link (name=null). + * @param linkFilename folder-link filename + * @param folderUrl linked folder URL + */ + LinkedGhidraFolder(Project activeProject, DomainFolder localParent, String linkFilename, + URL folderUrl) { + super(linkFilename); + + if (!GhidraURL.isServerRepositoryURL(folderUrl) && + !GhidraURL.isLocalProjectURL(folderUrl)) { + throw new IllegalArgumentException("Invalid Ghidra URL: " + folderUrl); + } + + this.activeProject = activeProject; + this.localParent = localParent; + this.folderUrl = folderUrl; + + linkedPathname = GhidraURL.getProjectPathname(folderUrl); + if (linkedPathname.length() > 0 && linkedPathname.endsWith(FileSystem.SEPARATOR)) { + linkedPathname = linkedPathname.substring(0, linkedPathname.length() - 1); + } + } + + /** + * Get the Ghidra URL associated with this linked folder's project or repository + * @return Ghidra URL associated with this linked folder's project or repository + */ + public URL getProjectURL() { + if (projectUrl == null) { + projectUrl = GhidraURL.getProjectURL(folderUrl); + } + return projectUrl; + } + + LinkedGhidraFolder getLinkedRootFolder() { + return this; + } + + DomainFolder getLinkedFolder(String linkedPath) throws IOException { + + ProjectData projectData = activeProject.addProjectView(getProjectURL(), false); + if (projectData == null) { + throw new FileNotFoundException(); + } + + DomainFolder folder = projectData.getFolder(linkedPath); + if (folder == null) { + throw new FileNotFoundException(folderUrl.toExternalForm()); + } + return folder; + } + + @Override + public String getLinkedPathname() { + return linkedPathname; + } + + @Override + public ProjectLocator getProjectLocator() { + return activeProject.getProjectLocator(); + } + + @Override + public ProjectData getProjectData() { + return activeProject.getProjectData(); + } + + @Override + public DomainFolder getParent() { + return localParent; + } + + @Override + public String toString() { + return "LinkedGhidraFolder: " + getPathname(); + } + + @Override + public Icon getIcon(boolean isOpen) { + return isOpen ? FOLDER_LINK_OPEN_ICON : FOLDER_LINK_CLOSED_ICON; + } + + @Override + public boolean isLinked() { + return true; + } +} 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 new file mode 100644 index 0000000000..e2577b2da8 --- /dev/null +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/LinkedGhidraSubFolder.java @@ -0,0 +1,294 @@ +/* ### + * 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 java.io.*; +import java.net.MalformedURLException; +import java.net.URL; + +import javax.swing.Icon; + +import ghidra.framework.model.*; +import ghidra.framework.protocol.ghidra.GhidraURL; +import ghidra.framework.store.FileSystem; +import ghidra.util.*; +import ghidra.util.exception.CancelledException; +import ghidra.util.task.TaskMonitor; + +class LinkedGhidraSubFolder implements LinkedDomainFolder { + + private final LinkedGhidraFolder linkedRootFolder; + private final LinkedGhidraSubFolder parent; + private final String folderName; + + LinkedGhidraSubFolder(String folderName) { + this.linkedRootFolder = getLinkedRootFolder(); + this.parent = null; // must override getParent() + this.folderName = folderName; + } + + LinkedGhidraSubFolder(LinkedGhidraSubFolder parent, String folderName) { + this.linkedRootFolder = parent.getLinkedRootFolder(); + this.parent = parent; + this.folderName = folderName; + } + + /** + * Get the linked root folder which corresponds to a folder-link + * (see {@link FolderLinkContentHandler}). + * @return linked root folder + */ + LinkedGhidraFolder getLinkedRootFolder() { + return linkedRootFolder; + } + + @Override + public boolean isInWritableProject() { + return false; // While project may be writeable this folder is not + } + + @Override + public DomainFolder getParent() { + return parent; + } + + @Override + public String getName() { + return folderName; + } + + @Override + public DomainFolder getLinkedFolder() throws IOException { + return linkedRootFolder.getLinkedFolder(getLinkedPathname()); + } + + @Override + public int compareTo(DomainFolder df) { + return getName().compareToIgnoreCase(df.getName()); + } + + @Override + public DomainFolder setName(String newName) throws InvalidNameException, IOException { + throw new ReadOnlyException("linked folder is read only"); + } + + @Override + public URL getSharedProjectURL() { + URL projectURL = getLinkedRootFolder().getProjectURL(); + if (GhidraURL.isServerRepositoryURL(projectURL)) { + String urlStr = projectURL.toExternalForm(); + if (urlStr.endsWith(FileSystem.SEPARATOR)) { + urlStr = urlStr.substring(0, urlStr.length() - 1); + } + String path = getLinkedPathname(); + if (!path.endsWith(FileSystem.SEPARATOR)) { + path += FileSystem.SEPARATOR; + } + try { + return new URL(urlStr + path); + } + catch (MalformedURLException e) { + // ignore + } + } + return null; + } + + @Override + public ProjectLocator getProjectLocator() { + return parent.getProjectLocator(); + } + + @Override + public ProjectData getProjectData() { + return parent.getProjectData(); + } + + @Override + public String getPathname() { + // pathname within project containing folder-link + // getParent() may return a non-linked folder + String path = getParent().getPathname(); + if (path.length() != FileSystem.SEPARATOR.length()) { + path += FileSystem.SEPARATOR; + } + path += folderName; + return path; + } + + /** + * Get the pathname of this folder within the linked-project/repository + * @return absolute linked folder path within the linked-project/repository + */ + public String getLinkedPathname() { + String path = parent.getLinkedPathname(); + if (!path.endsWith(FileSystem.SEPARATOR)) { + path += FileSystem.SEPARATOR; + } + path += folderName; + return path; + } + + @Override + public LinkedGhidraSubFolder[] getFolders() { + try { + DomainFolder linkedFolder = getLinkedFolder(); + DomainFolder[] folders = linkedFolder.getFolders(); + LinkedGhidraSubFolder[] linkedSubFolders = new LinkedGhidraSubFolder[folders.length]; + for (int i = 0; i < folders.length; i++) { + linkedSubFolders[i] = new LinkedGhidraSubFolder(this, folders[i].getName()); + } + return linkedSubFolders; + } + catch (IOException e) { + Msg.error(this, "Linked folder failure: " + e.getMessage()); + return new LinkedGhidraSubFolder[0]; + } + } + + @Override + public LinkedGhidraSubFolder getFolder(String name) { + try { + DomainFolder linkedFolder = getLinkedFolder(); + DomainFolder f = linkedFolder.getFolder(name); + if (f != null) { + return new LinkedGhidraSubFolder(this, name); + } + } + catch (IOException e) { + Msg.error(this, "Linked folder failure: " + e.getMessage()); + } + return null; + } + + @Override + public DomainFile[] getFiles() { + try { + DomainFolder linkedFolder = getLinkedFolder(); + 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()); + } + return linkedSubFolders; + } + catch (IOException e) { + Msg.error(this, "Linked folder failure: " + e.getMessage()); + return new LinkedGhidraFile[0]; + } + } + + /** + * Get the true file within this linked folder. + * @param name file name + * @return file or null if not found or error occurs + */ + public DomainFile getLinkedFileNoError(String name) { + try { + DomainFolder linkedFolder = getLinkedFolder(); + return linkedFolder.getFile(name); + } + catch (IOException e) { + Msg.error(this, "Linked folder failure: " + e.getMessage()); + } + return null; + } + + DomainFile getLinkedFile(String name) throws IOException { + DomainFolder linkedFolder = getLinkedFolder(); + DomainFile df = linkedFolder.getFile(name); + if (df == null) { + throw new FileNotFoundException("linked-file '" + name + "' not found"); + } + return df; + } + + @Override + public DomainFile getFile(String name) { + DomainFile f = getLinkedFileNoError(name); + return f != null ? new LinkedGhidraFile(this, name) : null; + } + + @Override + public boolean isEmpty() { + try { + DomainFolder linkedFolder = getLinkedFolder(); + return linkedFolder.isEmpty(); + } + catch (IOException e) { + Msg.error(this, "Linked folder failure: " + e.getMessage()); + // TODO: what should we return if folder not found or error occurs? + // True is returned to allow this method to be used to avoid continued access. + return true; + } + } + + @Override + public DomainFile createFile(String name, DomainObject obj, TaskMonitor monitor) + throws InvalidNameException, IOException, CancelledException { + throw new ReadOnlyException("linked folder is read only"); + } + + @Override + public DomainFile createFile(String name, File packFile, TaskMonitor monitor) + throws InvalidNameException, IOException, CancelledException { + throw new ReadOnlyException("linked folder is read only"); + } + + @Override + public DomainFolder createFolder(String name) throws InvalidNameException, IOException { + throw new ReadOnlyException("linked folder is read only"); + } + + @Override + public void delete() throws IOException { + throw new ReadOnlyException("linked folder is read only"); + } + + @Override + public DomainFolder moveTo(DomainFolder newParent) throws IOException { + throw new ReadOnlyException("linked folder is read only"); + } + + @Override + public DomainFolder copyTo(DomainFolder newParent, TaskMonitor monitor) + throws IOException, CancelledException { + DomainFolder linkedFolder = getLinkedFolder(); + return linkedFolder.copyTo(newParent, monitor); + } + + @Override + public DomainFile copyToAsLink(DomainFolder newParent) throws IOException { + DomainFolder linkedFolder = getLinkedFolder(); + return linkedFolder.copyToAsLink(newParent); + } + + @Override + public void setActive() { + // do nothing + } + + @Override + public String toString() { + return "LinkedGhidraSubFolder: " + getPathname(); + } + + @Override + public Icon getIcon(boolean isOpen) { + return isOpen ? OPEN_FOLDER_ICON : CLOSED_FOLDER_ICON; + } + +} diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/ProjectFileManager.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/ProjectFileManager.java index 9f1044f07a..11c0d51ab2 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/ProjectFileManager.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/ProjectFileManager.java @@ -16,13 +16,12 @@ package ghidra.framework.data; import java.io.*; -import java.net.URL; import java.util.*; +import docking.widgets.OptionDialog; import generic.timer.GhidraSwinglessTimer; import ghidra.framework.client.*; import ghidra.framework.model.*; -import ghidra.framework.protocol.ghidra.GhidraURL; import ghidra.framework.remote.User; import ghidra.framework.store.*; import ghidra.framework.store.FileSystem; @@ -82,6 +81,7 @@ public class ProjectFileManager implements ProjectData { private TaskMonitorAdapter projectDisposalMonitor = new TaskMonitorAdapter(); + private ProjectLock projectLock; private String owner; /** @@ -91,34 +91,44 @@ public class ProjectFileManager implements ProjectData { * @param resetOwner true to reset the project owner * @throws IOException if an i/o error occurs * @throws NotOwnerException if inProject is true and user is not owner + * @throws LockException if {@code isInWritableProject} is true and unable to establish project + * write lock (i.e., project in-use) * @throws FileNotFoundException if project directory not found */ public ProjectFileManager(ProjectLocator localStorageLocator, boolean isInWritableProject, - boolean resetOwner) throws NotOwnerException, IOException { + boolean resetOwner) throws NotOwnerException, IOException, LockException { this.localStorageLocator = localStorageLocator; - init(false, isInWritableProject); - if (resetOwner) { - owner = SystemUtilities.getUserName(); - properties.putString(OWNER, owner); - properties.writeState(); - } - else if (isInWritableProject && !SystemUtilities.getUserName().equals(owner)) { - if (owner == null) { - throw new NotOwnerException("Older projects may only be opened as a View.\n" + - "You must first create a new project or open an existing current project, \n" + - "then use the \"Project->View\" menu action to open the older project as a view.\n" + - "You can then drag old files into your active project."); + boolean success = false; + try { + init(false, isInWritableProject); + if (resetOwner) { + owner = SystemUtilities.getUserName(); + properties.putString(OWNER, owner); + properties.writeState(); + } + else if (isInWritableProject && !SystemUtilities.getUserName().equals(owner)) { + if (owner == null) { + throw new NotOwnerException("Older projects may only be opened as a View.\n" + + "You must first create a new project or open an existing current project, \n" + + "then use the \"Project->View\" menu action to open the older project as a view.\n" + + "You can then drag old files into your active project."); + } + throw new NotOwnerException("Project is owned by " + owner); } - throw new NotOwnerException("Project is owned by " + owner); - } - synchronized (fileSystem) { - getVersionedFileSystem(isInWritableProject); - rootFolderData = new RootGhidraFolderData(this, listenerList); - versionedFSListener = new MyFileSystemListener(); - versionedFileSystem.addFileSystemListener(versionedFSListener); - scheduleUserDataReconcilation(); + synchronized (fileSystem) { + getVersionedFileSystem(isInWritableProject); + rootFolderData = new RootGhidraFolderData(this, listenerList); + initVersionedFSListener(); + scheduleUserDataReconcilation(); + } + success = true; + } + finally { + if (!success) { + dispose(); + } } } @@ -128,34 +138,75 @@ public class ProjectFileManager implements ProjectData { * @param repository a repository if this is a shared project or null if it is a private project * @param isInWritableProject true if project content is writable, false if project is read-only * @throws IOException if an i/o error occurs + * @throws LockException if {@code isInWritableProject} is true and unable to establish project + * lock (i.e., project in-use) */ public ProjectFileManager(ProjectLocator localStorageLocator, RepositoryAdapter repository, - boolean isInWritableProject) throws IOException { + boolean isInWritableProject) throws IOException, LockException { this.localStorageLocator = localStorageLocator; this.repository = repository; - init(true, isInWritableProject); - synchronized (fileSystem) { - createVersionedFileSystem(); - rootFolderData = new RootGhidraFolderData(this, listenerList); - versionedFSListener = new MyFileSystemListener(); - versionedFileSystem.addFileSystemListener(versionedFSListener); + boolean success = false; + try { + init(true, isInWritableProject); + synchronized (fileSystem) { + createVersionedFileSystem(); + rootFolderData = new RootGhidraFolderData(this, listenerList); + initVersionedFSListener(); + } + success = true; + } + finally { + if (!success) { + dispose(); + } } } - ProjectFileManager(LocalFileSystem fileSystem, FileSystem versionedFileSystem) { + /** + * Constructor for test use only. A non-existing {@link ProjectLocator} is used without + * project locking. + * @param fileSystem an existing non-versioned local file-system + * @param versionedFileSystem an existing versioned file-system + * @throws IOException if an IO error occurs + */ + ProjectFileManager(LocalFileSystem fileSystem, FileSystem versionedFileSystem) + throws IOException { this.localStorageLocator = new ProjectLocator(null, "Test"); owner = SystemUtilities.getUserName(); - synchronized (fileSystem) { - this.fileSystem = fileSystem; - this.versionedFileSystem = versionedFileSystem; - rootFolderData = new RootGhidraFolderData(this, listenerList); - versionedFSListener = new MyFileSystemListener(); - versionedFileSystem.addFileSystemListener(versionedFSListener); - scheduleUserDataReconcilation(); + boolean success = false; + try { + synchronized (fileSystem) { + this.fileSystem = fileSystem; + this.versionedFileSystem = versionedFileSystem; + rootFolderData = new RootGhidraFolderData(this, listenerList); + initVersionedFSListener(); + scheduleUserDataReconcilation(); + success = true; + } + } + finally { + if (!success) { + dispose(); + } } } - private void init(boolean create, boolean isInWritableProject) throws IOException { + private void initVersionedFSListener() throws IOException { + // Listener not installed for local read-only versioned file-system + if (versionedFileSystem.isShared() || !versionedFileSystem.isReadOnly()) { + if (versionedFSListener == null) { + versionedFSListener = new MyFileSystemListener(); + } + versionedFileSystem.addFileSystemListener(versionedFSListener); + } + else { + versionedFSListener = null; + } + } + + private void init(boolean create, boolean isInWritableProject) + throws IOException, LockException { + projectDir = localStorageLocator.getProjectDir(); properties = new PropertyFile(projectDir, PROPERTY_FILENAME, "/", PROPERTY_FILENAME); if (create) { @@ -163,11 +214,13 @@ public class ProjectFileManager implements ProjectData { throw new DuplicateFileException( "Project directory already exists: " + projectDir.getCanonicalPath()); } + File markerFile = localStorageLocator.getMarkerFile(); + if (markerFile.exists()) { + throw new DuplicateFileException( + "Project marker file already exists: " + markerFile.getCanonicalPath()); + } projectDir.mkdir(); localStorageLocator.getMarkerFile().createNewFile(); - owner = SystemUtilities.getUserName(); - properties.putString(OWNER, owner); - properties.writeState(); } else { if (!projectDir.isDirectory()) { @@ -178,22 +231,92 @@ public class ProjectFileManager implements ProjectData { throw new ReadOnlyException( "Project " + localStorageLocator.getName() + " is read-only"); } - properties.readState(); owner = properties.getString(OWNER, SystemUtilities.getUserName()); } - else if (isInWritableProject) { - owner = SystemUtilities.getUserName(); - properties.putString(OWNER, owner); - properties.writeState(); - } else { owner = ""; // Unknown owner } } + + if (isInWritableProject) { + initLock(create); + } + getPrivateFileSystem(create, isInWritableProject); getUserFileSystem(isInWritableProject); } + private void initLock(boolean creatingProject) throws LockException, IOException { + this.projectLock = getProjectLock(localStorageLocator, !creatingProject); + if (projectLock == null) { + throw new LockException("Unable to lock project! " + localStorageLocator); + } + + if (!properties.exists()) { + owner = SystemUtilities.getUserName(); + properties.putString(OWNER, owner); + properties.writeState(); + } + } + + /** + * Creates a ProjectLock and attempts to lock it. This handles the case + * where the project was previously locked. + * + * @param locator the project locator + * @param allowInteractiveForce if true, when a lock cannot be obtained, the + * user will be prompted + * @return A locked ProjectLock or null if lock fails + */ + private ProjectLock getProjectLock(ProjectLocator locator, boolean allowInteractiveForce) { + ProjectLock lock = new ProjectLock(locator); + if (lock.lock()) { + return lock; + } + + // in headless mode, just spit out an error + if (!allowInteractiveForce || SystemUtilities.isInHeadlessMode()) { + return null; + } + + String projectStr = "Project: " + HTMLUtilities.escapeHTML(locator.getLocation()) + + System.getProperty("file.separator") + HTMLUtilities.escapeHTML(locator.getName()); + String lockInformation = lock.getExistingLockFileInformation(); + if (!lock.canForceLock()) { + Msg.showInfo(getClass(), null, "Project Locked", + "Project is locked. You have another instance of Ghidra
    " + + "already running with this project open (locally or remotely).

    " + + projectStr + "

    " + "Lock information: " + lockInformation); + return null; + } + + int userChoice = OptionDialog.showOptionDialog(null, "Project Locked - Delete Lock?", + "Project is locked. You may have another instance of Ghidra
    " + + "already running with this project opened (locally or remotely).
    " + projectStr + + "

    " + "If this is not the case, you can delete the lock file:
    " + + locator.getProjectLockFile().getAbsolutePath() + ".

    " + + "Lock information: " + lockInformation, + "Delete Lock", OptionDialog.QUESTION_MESSAGE); + if (userChoice == OptionDialog.OPTION_ONE) { // Delete Lock + if (lock.forceLock()) { + return lock; + } + + Msg.showError(this, null, "Error", "Attempt to force lock failed! " + locator); + } + return null; + } + + /** + * Determine if the specified project location currently has a write lock. + * @param locator project storage locator + * @return true if project data current has write-lock else false + */ + public static boolean isLocked(ProjectLocator locator) { + ProjectLock lock = new ProjectLock(locator); + return lock.isLocked(); + } + @Override public int getMaxNameLength() { return fileSystem.getMaxNameLength(); @@ -314,8 +437,9 @@ public class ProjectFileManager implements ProjectData { /** * Change the versioned filesystem associated with this project file manager. - * This method is provided for testing. Care should be taken when using a - * LocalFileSystem in a shared capacity since locking is not supported. + * This method is provided for testing (see {@code FakeSharedProject}). + * Care should be taken when using a LocalFileSystem in a shared capacity since + * locking is not supported. * @param fs versioned filesystem * @throws IOException if an IO error occurs */ @@ -323,9 +447,11 @@ public class ProjectFileManager implements ProjectData { if (!fs.isVersioned()) { throw new IllegalArgumentException("versioned filesystem required"); } - versionedFileSystem.removeFileSystemListener(versionedFSListener); + if (versionedFSListener != null) { + versionedFileSystem.removeFileSystemListener(versionedFSListener); + } versionedFileSystem = fs; - versionedFileSystem.addFileSystemListener(versionedFSListener); + initVersionedFSListener(); rootFolderData.setVersionedFileSystem(versionedFileSystem); } @@ -385,18 +511,36 @@ public class ProjectFileManager implements ProjectData { } @Override - public GhidraFolder getFolder(String path) { + public DomainFolder getFolder(String path) { int len = path.length(); if (len == 0 || path.charAt(0) != FileSystem.SEPARATOR_CHAR) { throw new IllegalArgumentException( "Absolute path must begin with '" + FileSystem.SEPARATOR_CHAR + "'"); } - try { - return getRootFolder().getFolderPathData(path).getDomainFolder(); + + DomainFolder folder = getRootFolder(); + String[] split = path.split(FileSystem.SEPARATOR); + if (split.length == 0) { + return folder; } - catch (FileNotFoundException e) { - return null; + + for (int i = 1; i < split.length; i++) { + DomainFolder subFolder = folder.getFolder(split[i]); + if (subFolder == null) { + // Check for folder link-file if folder not found + // NOTE: if real folder name matches link-file name it will block + // use of folder link-file. + DomainFile file = folder.getFile(split[i]); + if (file != null && file.isLinkFile()) { + subFolder = file.followLink(); + } + if (subFolder == null) { + return null; + } + } + folder = subFolder; } + return folder; } @Override @@ -468,19 +612,6 @@ public class ProjectFileManager implements ProjectData { return fileIndex.getFileByID(fileID); } - @Override - public URL getSharedFileURL(String path) { - if (repository != null) { - DomainFile df = getFile(path); - if (df != null && df.isVersioned()) { - ServerInfo server = repository.getServerInfo(); - return GhidraURL.makeURL(server.getServerName(), server.getPortNumber(), - repository.getName(), path); - } - } - return null; - } - public void releaseDomainFiles(Object consumer) { for (DomainObjectAdapter domainObj : openDomainObjects.values()) { try { @@ -707,7 +838,11 @@ public class ProjectFileManager implements ProjectData { monitor.checkCanceled(); LocalFolderItem item = fileSystem.getItem(folderPath, name); if (item.getCheckoutId() != FolderItem.DEFAULT_CHECKOUT_ID) { - checkoutList.add(new GhidraFile(getFolder(folderPath), name)); + GhidraFolderData folderData = + getRootFolderData().getFolderPathData(folderPath, false); + if (folderData != null) { + checkoutList.add(new GhidraFile(folderData.getDomainFolder(), name)); + } } } @@ -1037,16 +1172,24 @@ public class ProjectFileManager implements ProjectData { listenerList.clearAll(); } - synchronized (fileSystem) { - rootFolderData.dispose(); - fileSystem.dispose(); - versionedFileSystem.dispose(); - versionedFileSystem.removeFileSystemListener(versionedFSListener); - if (repository != null) { - repository.disconnect(); - repository = null; + if (fileSystem != null) { + synchronized (fileSystem) { + if (versionedFSListener != null) { + versionedFileSystem.removeFileSystemListener(versionedFSListener); + } + if (repository != null) { + repository.disconnect(); + repository = null; + } + rootFolderData.dispose(); + versionedFileSystem.dispose(); + fileSystem.dispose(); } } + + if (projectLock != null) { + projectLock.release(); + } } GhidraFolderData getRootFolderData() { diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/ProjectLock.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/ProjectLock.java similarity index 97% rename from Ghidra/Framework/Project/src/main/java/ghidra/framework/project/ProjectLock.java rename to Ghidra/Framework/Project/src/main/java/ghidra/framework/data/ProjectLock.java index 1509309fb0..cb1552bc45 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/ProjectLock.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/ProjectLock.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package ghidra.framework.project; +package ghidra.framework.data; import java.io.File; diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/URLLinkObject.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/URLLinkObject.java new file mode 100644 index 0000000000..c04cd40978 --- /dev/null +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/URLLinkObject.java @@ -0,0 +1,101 @@ +/* ### + * 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 java.io.File; +import java.io.IOException; +import java.net.URL; + +import javax.help.UnsupportedOperationException; + +import db.DBHandle; +import ghidra.framework.model.DomainFile; +import ghidra.framework.model.DomainObject; +import ghidra.framework.protocol.ghidra.GhidraURL; +import ghidra.util.exception.CancelledException; +import ghidra.util.task.TaskMonitor; + +/** + * {@code DomainObjectAdapterLink} object provides a Ghidra URL (see {@link GhidraURL}) wrapper + * where the URL is intended to refer to a {@link DomainFile} within another local or remote + * project/repository. Link files which correspond to this type of {@link DomainObject} are + * not intended to be modified and should be created or deleted. A checkout may be used when + * an offline copy is required but otherwise serves no purpose since a modification and checkin + * is not supported. + */ +public class URLLinkObject extends DomainObjectAdapterDB { + + // Use a reduced DB buffer size to reduce file size for minimal content. + // This will allow a 4-KByte DB buffer file to hold a URL upto ~470 bytes long. + // Longer URLs will rely on 1-KByte chained buffers which will increase file length. + private static final int DB_BUFFER_SIZE = 1024; + + private URL url; + + /** + * Constructs a new link file object + * @param name link name + * @param ghidraUrl link URL + * @param consumer the object that is using this program. + * @throws IOException if there is an error accessing the database or invalid URL specified. + */ + public URLLinkObject(String name, URL ghidraUrl, Object consumer) throws IOException { + super(new DBHandle(DB_BUFFER_SIZE), name, 500, consumer); + metadata.put(LinkHandler.URL_METADATA_KEY, ghidraUrl.toString()); + updateMetadata(); + } + + /** + * Constructs a link file object from a DBHandle (read-only) + * @param dbh a handle to an open program database. + * @param consumer the object that keeping the program open. + * @throws IOException if an error accessing the database occurs. + */ + public URLLinkObject(DBHandle dbh, Object consumer) throws IOException { + super(dbh, "Untitled", 500, consumer); + loadMetadata(); + String urlText = metadata.get(LinkHandler.URL_METADATA_KEY); + if (urlText == null) { + throw new IOException("Null link object"); + } + url = new URL(urlText); + } + + @Override + public String getDescription() { + return "Link-File"; + } + + /** + * Get link URL + * @return link URL + */ + public URL getLink() { + return url; + } + + @Override + public final boolean isChangeable() { + return false; + } + + @Override + public final void saveToPackedFile(File outputFile, TaskMonitor monitor) + throws IOException, CancelledException { + throw new UnsupportedOperationException(); + } + +} 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 1d4617c933..2175211e14 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 @@ -40,6 +40,8 @@ import generic.theme.GIcon; import ghidra.app.plugin.PluginCategoryNames; import ghidra.framework.GenericRunInfo; import ghidra.framework.client.*; +import ghidra.framework.data.FolderLinkContentHandler; +import ghidra.framework.data.LinkedGhidraFolder; import ghidra.framework.main.datatable.ProjectDataTablePanel; import ghidra.framework.main.datatree.*; import ghidra.framework.main.projectdata.actions.*; @@ -69,7 +71,7 @@ import ghidra.util.filechooser.GhidraFileFilter; ) //@formatter:on public class FrontEndPlugin extends Plugin - implements FrontEndService, RemoteAdapterListener, ProgramaticUseOnly { + implements FrontEndService, RemoteAdapterListener, ProjectViewListener, ProgramaticUseOnly { private final static String TITLE_PREFIX = "Ghidra: "; private final static String EXPORT_TOOL_ACTION_NAME = "Export Tool"; @@ -128,6 +130,7 @@ public class FrontEndPlugin extends Plugin private ClearCutAction clearCutAction; private ProjectDataCopyAction copyAction; private ProjectDataPasteAction pasteAction; + private ProjectDataPasteLinkAction pasteLinkAction; private ProjectDataRenameAction renameAction; private ProjectDataOpenDefaultToolAction openAction; private ProjectDataExpandAction expandAction; @@ -217,6 +220,7 @@ public class FrontEndPlugin extends Plugin clearCutAction = new ClearCutAction(owner); copyAction = new ProjectDataCopyAction(owner, groupName); pasteAction = new ProjectDataPasteAction(owner, groupName); + pasteLinkAction = new ProjectDataPasteLinkAction(owner, groupName); groupName = "Delete/Rename"; renameAction = new ProjectDataRenameAction(owner, groupName); @@ -239,6 +243,7 @@ public class FrontEndPlugin extends Plugin tool.addAction(clearCutAction); tool.addAction(copyAction); tool.addAction(pasteAction); + tool.addAction(pasteLinkAction); tool.addAction(deleteAction); tool.addAction(openAction); tool.addAction(renameAction); @@ -393,6 +398,10 @@ public class FrontEndPlugin extends Plugin toolChest.addToolChestChangeListener(toolBar); toolChest.addToolChestChangeListener(toolChestChangeListener); createToolSpecificOpenActions(); + + // Add project view listener + activeProject.addProjectViewListener(this); + // Add the repository listener RepositoryAdapter repository = activeProject.getRepository(); if (repository != null) { @@ -403,6 +412,16 @@ public class FrontEndPlugin extends Plugin // gui.validate(); } + @Override + public void viewedProjectAdded(URL projectView) { + SwingUtilities.invokeLater(() -> rebuildRecentMenus()); + } + + @Override + public void viewedProjectRemoved(URL projectView) { + SwingUtilities.invokeLater(() -> rebuildRecentMenus()); + } + /** * sets the name of the project, using the default name if no project is active */ @@ -1074,24 +1093,56 @@ public class FrontEndPlugin extends Plugin } public void openDomainFile(DomainFile domainFile) { - Project project = tool.getProject(); - final ToolServices toolServices = project.getToolServices(); - ToolTemplate defaultToolTemplate = toolServices.getDefaultToolTemplate(domainFile); - if (defaultToolTemplate == null) { - // assume no tools in the tool chest - Msg.showInfo(this, tool.getToolFrame(), "Cannot Find Tool", - "Cannot find tool to open file: " + - HTMLUtilities.escapeHTML(domainFile.getName()) + - ".

    Make sure you have an appropriate tool installed
    from the " + - "Tools->Import Default Tools... menu. Alternatively, you can " + - "use Tool->Set Tool Associations menu to change how Ghidra " + - "opens this type of file"); + if (FolderLinkContentHandler.FOLDER_LINK_CONTENT_TYPE.equals(domainFile.getContentType())) { + showLinkedFolder(domainFile); return; } - ToolButton button = toolBar.getToolButtonForToolConfig(defaultToolTemplate); - button.launchTool(domainFile); + Project project = tool.getProject(); + final ToolServices toolServices = project.getToolServices(); + ToolTemplate defaultToolTemplate = toolServices.getDefaultToolTemplate(domainFile); + if (defaultToolTemplate != null) { + ToolButton button = toolBar.getToolButtonForToolConfig(defaultToolTemplate); + if (button != null) { + button.launchTool(domainFile); + return; + } + } + + Msg.showInfo(this, tool.getToolFrame(), "Cannot Find Tool", + "Cannot find tool to open file: " + + HTMLUtilities.escapeHTML(domainFile.getName()) + + ".

    Make sure you have an appropriate tool installed
    from the " + + "Tools->Import Default Tools... menu. Alternatively, you can " + + "use Tool->Set Tool Associations menu to change how Ghidra " + + "opens this type of file"); + } + + private void showLinkedFolder(DomainFile domainFile) { + + try { + LinkedGhidraFolder linkedFolder = + FolderLinkContentHandler.getReadOnlyLinkedFolder(domainFile); + if (linkedFolder == null) { + return; // unsupported use + } + + ProjectDataTreePanel dtp = projectDataPanel.openView(linkedFolder.getProjectURL()); + if (dtp == null) { + return; + } + + DomainFolder domainFolder = linkedFolder.getLinkedFolder(); + if (domainFolder != null) { + // delayed to ensure tree is displayd + Swing.runLater(() -> dtp.selectDomainFolder(domainFolder)); + } + } + catch (IOException e) { + Msg.showError(this, projectDataPanel, "Linked-folder failure: " + domainFile.getName(), + e); + } } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/ProjectActionManager.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/ProjectActionManager.java index 4ee788cd3d..6ece295885 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/ProjectActionManager.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/ProjectActionManager.java @@ -554,10 +554,15 @@ class ProjectActionManager { return; } - ProjectDataPanel pdp = plugin.getProjectDataPanel(); - pdp.openView(view); - // also update the recent views menu - plugin.rebuildRecentMenus(); + try { + activeProject.addProjectView(view, true); // listener will trigger data panel panel display + } + catch (IOException e) { + ProjectManager projectManager = tool.getProjectManager(); + projectManager.forgetViewedProject(view); + Msg.showError(getClass(), tool.getToolFrame(), "Error Adding View", + "Failed to view project/repository: " + e.getMessage(), e); + } } private void editProjectAccess() { diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/ProjectDataPanel.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/ProjectDataPanel.java index 7606c410e1..3e9cb85d9e 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/ProjectDataPanel.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/ProjectDataPanel.java @@ -27,6 +27,7 @@ import javax.swing.*; import docking.ActionContext; import docking.ComponentProvider; import docking.widgets.tabbedpane.DockingTabRenderer; +import ghidra.framework.client.NotConnectedException; import ghidra.framework.main.datatable.ProjectDataTablePanel; import ghidra.framework.main.datatree.ProjectDataTreePanel; import ghidra.framework.model.*; @@ -40,7 +41,7 @@ import help.HelpService; * Manages the data tree for the active project, and the trees for the * project views. */ -class ProjectDataPanel extends JSplitPane { +class ProjectDataPanel extends JSplitPane implements ProjectViewListener { private final static String BORDER_PREFIX = "Active Project: "; private final static String READ_ONLY_BORDER = "READ-ONLY Project Data"; private final static int TYPICAL_NUM_VIEWS = 2; @@ -155,6 +156,21 @@ class ProjectDataPanel extends JSplitPane { setViewsVisible(views.length > 0); } + @Override + public void viewedProjectAdded(URL projectView) { + SwingUtilities.invokeLater(() -> openView(projectView)); + } + + @Override + public void viewedProjectRemoved(URL projectView) { + SwingUtilities.invokeLater(() -> { + ProjectDataTreePanel dtp = getViewPanel(projectView); + if (dtp != null) { + viewRemoved(dtp, projectView, false); + } + }); + } + private void clearReadOnlyViews() { readOnlyTab.removeAll(); readOnlyViews.clear(); @@ -167,7 +183,13 @@ class ProjectDataPanel extends JSplitPane { this.setDividerLocation(visible ? DIVIDER_LOCATION : 1.0); } - void openView(URL projectView) { + /** + * Open specified project URL in tabbed READ-Only project views + * @param projectView project URL to be opened/added to view + * @return corresponding tree panel or null on failure + */ + ProjectDataTreePanel openView(URL projectView) { + ProjectManager projectManager = tool.getProjectManager(); Project activeProject = tool.getProject(); @@ -176,19 +198,23 @@ class ProjectDataPanel extends JSplitPane { if (dtp != null) { readOnlyTab.setSelectedComponent(dtp); try { - activeProject.addProjectView(projectView); + activeProject.addProjectView(projectView, true); projectManager.rememberViewedProject(projectView); + return dtp; } catch (Exception e) { projectManager.forgetViewedProject(projectView); Msg.showError(getClass(), tool.getToolFrame(), "Error Adding View", e.toString()); } - return; + return null; } try { // TODO: addProjectView should be done in a model task - ProjectData projectData = activeProject.addProjectView(projectView); + ProjectData projectData = activeProject.addProjectView(projectView, true); + if (projectData == null) { + return null; // repository connection may have been cancelled + } projectManager.rememberViewedProject(projectView); String viewName = projectData.getProjectLocator().getName(); final ProjectDataTreePanel newPanel = @@ -204,13 +230,20 @@ class ProjectDataPanel extends JSplitPane { readOnlyTab.setSelectedIndex(0); readOnlyViews.put(projectData.getProjectLocator(), newPanel); setViewsVisible(true); + return newPanel; + } + catch (NotConnectedException e) { + // already handled (e..g, cancelled login) - ignore } catch (Exception e) { projectManager.forgetViewedProject(projectView); Msg.showError(getClass(), tool.getToolFrame(), "Error Adding View", - "Failed to view project/repository: " + e.getMessage()); + "Failed to view project/repository: " + e.getMessage(), e); } - validate(); + finally { + validate(); + } + return null; } ProjectLocator[] getProjectViews() { @@ -315,6 +348,7 @@ class ProjectDataPanel extends JSplitPane { treePanel.setProjectData(project.getName(), project.getProjectData()); tablePanel.setProjectData(project.getName(), project.getProjectData()); populateReadOnlyViews(project); + project.addProjectViewListener(this); } else { tablePanel.setProjectData("No Active Project", null); diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatable/ProjectDataTablePanel.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatable/ProjectDataTablePanel.java index 559ea7631f..28d95d2eed 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatable/ProjectDataTablePanel.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatable/ProjectDataTablePanel.java @@ -182,9 +182,12 @@ public class ProjectDataTablePanel extends JPanel { this.projectData.removeDomainFolderChangeListener(changeListener); model.setProjectData(null); SystemUtilities.runSwingLater(() -> { - GGlassPane glassPane = (GGlassPane) gTable.getRootPane().getGlassPane(); - glassPane.removePainter(painter); - glassPane.addPainter(painter); + JRootPane rootPane = gTable.getRootPane(); + if (rootPane != null) { + GGlassPane glassPane = (GGlassPane) rootPane.getGlassPane(); + glassPane.removePainter(painter); + glassPane.addPainter(painter); + } }); } } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatable/ProjectTreeAction.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatable/ProjectTreeAction.java index 241af898ef..a62998cea7 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatable/ProjectTreeAction.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatable/ProjectTreeAction.java @@ -70,7 +70,7 @@ public abstract class ProjectTreeAction extends DockingAction { @Override public boolean isAddToPopup(ActionContext context) { - if (!isEnabledForContext(context)) { + if (!(context instanceof FrontEndProjectTreeContext)) { return false; } return isAddToPopup((FrontEndProjectTreeContext) context); 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 e8c60ab6b7..99da701708 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 @@ -176,40 +176,15 @@ class ChangeManager implements DomainFolderChangeListener { if (lazy && !folderNode.isLoaded()) { return null; // not visited } - // must look at all children since a folder and file may have the same name - boolean found = false; - for (GTreeNode node : folderNode.getChildren()) { - if (!(node instanceof DomainFolderNode)) { - continue; - } - if (name.equals(node.getName())) { - folderNode = (DomainFolderNode) node; - found = true; - break; - } - } - if (!found) { + folderNode = + (DomainFolderNode) folderNode.getChild(name, n -> (n instanceof DomainFolderNode)); + if (folderNode == null) { return null; } } return folderNode; } -// private DomainFileNode findDomainFileNode(DomainFolder parent, String name, boolean lazy) { -// DomainFolderNode folderNode = findDomainFolderNode(parent, lazy); -// if (folderNode == null) { -// return null; -// } -// if (lazy && !folderNode.isChildrenLoadedOrInProgress()) { -// return null; // not visited -// } -// GTreeNode child = folderNode.getChild(name); -// if (child instanceof DomainFileNode) { -// return (DomainFileNode) child; -// } -// return null; -// } - private DomainFileNode findDomainFileNode(DomainFile domainFile, boolean lazy) { DomainFolderNode folderNode = findDomainFolderNode(domainFile.getParent(), lazy); if (folderNode == null) { @@ -219,11 +194,8 @@ class ChangeManager implements DomainFolderChangeListener { return null; // not visited } - GTreeNode child = folderNode.getChild(domainFile.getName()); - if (child instanceof DomainFileNode) { - return (DomainFileNode) child; - } - return null; + return (DomainFileNode) folderNode.getChild(domainFile.getName(), + n -> (n instanceof DomainFileNode)); } private void updateFolderNode(DomainFolder parent) { diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/CheckInTask.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/CheckInTask.java index 1f283a3a60..e68dfa1c0e 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/CheckInTask.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/CheckInTask.java @@ -58,7 +58,7 @@ public class CheckInTask extends VersionControlTask implements CheckinHandler { throw new CancelledException(); } if (actionID != VersionControlDialog.APPLY_TO_ALL) { - showDialog(false, df.getName()); // false==> checking in vs. + showDialog(false, df.getName(), df.isLinkFile()); // false==> checking in vs. // adding to version control if (actionID == VersionControlDialog.CANCEL) { monitor.cancel(); 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 400be85269..785a8c93d2 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 @@ -16,6 +16,7 @@ package ghidra.framework.main.datatree; import java.io.IOException; +import java.net.URL; import java.text.SimpleDateFormat; import java.util.Date; @@ -24,6 +25,8 @@ import javax.swing.SwingWorker; import docking.widgets.tree.GTreeNode; import generic.theme.GIcon; +import ghidra.framework.data.FolderLinkContentHandler; +import ghidra.framework.data.LinkHandler; import ghidra.framework.model.DomainFile; import ghidra.framework.store.ItemCheckoutStatus; import ghidra.util.*; @@ -174,7 +177,7 @@ public class DomainFileNode extends GTreeNode implements Cuttable { if (domainFile.isHijacked()) { newDisplayName += " (hijacked)"; } - else if (domainFile.isVersioned()) { + else if (domainFile.isVersioned() && !domainFile.isLinkFile()) { int versionNumber = domainFile.getVersion(); String versionStr = "" + versionNumber; @@ -208,20 +211,34 @@ public class DomainFileNode extends GTreeNode implements Cuttable { } private void setToolTipText() { - String newToolTipText = toolTipText; + String newToolTipText = null; if (domainFile.isInWritableProject() && domainFile.isHijacked()) { newToolTipText = "Hijacked file should be deleted or renamed"; } else { - long lastModified = domainFile.getLastModifiedTime(); - newToolTipText = "Last Modified " + formatter.format(new Date(lastModified)); + StringBuilder buf = new StringBuilder(); + try { + if (domainFile.isLinkFile()) { + URL url = LinkHandler.getURL(domainFile); + buf.append("URL: "); + buf.append(StringUtilities.trimMiddle(url.toString(), 120)); + newToolTipText = buf.toString(); + } + } + catch (IOException e1) { + // ignore + } + if (newToolTipText == null) { + long lastModified = domainFile.getLastModifiedTime(); + newToolTipText = "Last Modified " + formatter.format(new Date(lastModified)); + } if (domainFile.isCheckedOut()) { try { ItemCheckoutStatus status = domainFile.getCheckoutStatus(); if (status != null) { - newToolTipText = HTMLUtilities.toHTML( - "Checked out " + formatter.format(new Date(status.getCheckoutTime())) + - ";\n" + newToolTipText); + newToolTipText = "Checked out " + + formatter.format(new Date(status.getCheckoutTime())) + + "\n" + newToolTipText; } } catch (IOException e) { @@ -232,6 +249,7 @@ public class DomainFileNode extends GTreeNode implements Cuttable { if (domainFile.isReadOnly()) { newToolTipText += " (read only)"; } + newToolTipText = HTMLUtilities.toLiteralHTML(newToolTipText, 0); } toolTipText = newToolTipText; } @@ -243,12 +261,38 @@ public class DomainFileNode extends GTreeNode implements Cuttable { @Override public int compareTo(GTreeNode node) { + // Goal is to sort folder link-files similar to a folder if (node instanceof DomainFolderNode) { + if (isFolderLink()) { + int c = super.compareTo(node); + if (c != 0) { + // A link-file name is permitted to match another folder node but + // should not be considered equal + return c; + } + } return 1; } + if (node instanceof DomainFileNode) { + DomainFileNode otherFileNode = (DomainFileNode) node; + if (isFolderLink()) { + if (otherFileNode.isFolderLink()) { + return super.compareTo(node); + } + return -1; + } + else if (otherFileNode.isFolderLink()) { + return 1; + } + } return super.compareTo(node); } + boolean isFolderLink() { + return FolderLinkContentHandler.FOLDER_LINK_CONTENT_TYPE + .equals(domainFile.getContentType()); + } + @Override public void valueChanged(Object newValue) { if (newValue.equals(getName())) { 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 1b027ed0b3..6c70770541 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 @@ -22,20 +22,18 @@ import javax.swing.Icon; import docking.widgets.tree.GTreeLazyNode; import docking.widgets.tree.GTreeNode; -import generic.theme.GIcon; import ghidra.framework.model.*; -import ghidra.util.InvalidNameException; -import ghidra.util.Msg; +import ghidra.util.*; import resources.ResourceManager; /** * Class to represent a node in the Data tree. */ public class DomainFolderNode extends GTreeLazyNode implements Cuttable { - private static final Icon ENABLED_OPEN_FOLDER = - new GIcon("icon.datatree.node.domain.folder.open"); - private static final Icon ENABLED_CLOSED_FOLDER = - new GIcon("icon.datatree.node.domain.folder.closed"); + + private static final Icon ENABLED_OPEN_FOLDER = DomainFolder.OPEN_FOLDER_ICON; + private static final Icon ENABLED_CLOSED_FOLDER = DomainFolder.CLOSED_FOLDER_ICON; + private static final Icon DISABLED_OPEN_FOLDER = ResourceManager.getDisabledIcon(ENABLED_OPEN_FOLDER); private static final Icon DISABLED_CLOSED_FOLDER = @@ -55,11 +53,18 @@ public class DomainFolderNode extends GTreeLazyNode implements Cuttable { // TODO: how can the folder be null?...doesn't really make sense...I don't think it ever is if (domainFolder != null) { - toolTipText = domainFolder.getPathname(); + toolTipText = StringUtilities.trimMiddle(domainFolder.getPathname(), 120); + toolTipText = HTMLUtilities.toLiteralHTML(toolTipText, 0); isEditable = domainFolder.isInWritableProject(); } } + @Override + public boolean isAutoExpandPermitted() { + // Prevent auto-expansion through linked-folders + return !domainFolder.isLinked(); + } + /** * Get the domain folder; returns null if this node represents a domain file. * @@ -96,6 +101,10 @@ public class DomainFolderNode extends GTreeLazyNode implements Cuttable { @Override public Icon getIcon(boolean expanded) { + if (domainFolder instanceof LinkedDomainFolder) { + // NOTE: cut operation not supported + return ((LinkedDomainFolder) domainFolder).getIcon(expanded); + } if (expanded) { return isCut ? DISABLED_OPEN_FOLDER : ENABLED_OPEN_FOLDER; } @@ -119,8 +128,12 @@ public class DomainFolderNode extends GTreeLazyNode implements Cuttable { @Override protected List generateChildren() { + List children = new ArrayList<>(); - if (domainFolder != null) { + if (domainFolder != null && !domainFolder.isEmpty()) { + + // NOTE: isEmpty() is used to avoid multiple failed connection attempts on this folder + DomainFolder[] folders = domainFolder.getFolders(); for (DomainFolder folder : folders) { children.add(new DomainFolderNode(folder, filter)); @@ -128,6 +141,13 @@ public class DomainFolderNode extends GTreeLazyNode implements Cuttable { DomainFile[] files = domainFolder.getFiles(); for (DomainFile domainFile : files) { + if (domainFile.isLinkFile() && filter != null && filter.followLinkedFolders()) { + DomainFolder folder = domainFile.followLink(); + if (folder != null) { + children.add(new DomainFolderNode(folder, filter)); + continue; + } + } if (filter == null || filter.accept(domainFile)) { children.add(new DomainFileNode(domainFile)); } @@ -173,7 +193,8 @@ public class DomainFolderNode extends GTreeLazyNode implements Cuttable { @Override public int compareTo(GTreeNode node) { if (node instanceof DomainFileNode) { - return -1; + // defer to DomainFileNode for comparison + return -((DomainFileNode) node).compareTo(this); } return super.compareTo(node); } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/ProjectDataTreePanel.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/ProjectDataTreePanel.java index 77aebb4327..5ef74d2d16 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/ProjectDataTreePanel.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/ProjectDataTreePanel.java @@ -423,6 +423,9 @@ public class ProjectDataTreePanel extends JPanel { root = createRootNode(projectName); tree = new DataTree(tool, root); + if (!isActiveProject) { + tree.setName(tree.getName() + ": " + projectName); + } if (plugin != null) { tree.addGTreeSelectionListener(e -> { diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/VersionControlDialog.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/VersionControlDialog.java index 4a10a953fb..f05062b9b6 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/VersionControlDialog.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/VersionControlDialog.java @@ -117,12 +117,6 @@ public class VersionControlDialog extends DialogComponentProvider { return keepFileCB.isSelected(); } - void setCreateKeepFile(boolean selected) { - if (!addToVersionControl) { - keepFileCB.setSelected(selected); - } - } - /** * Return the comments for the add to version control. * @return may be the empty string @@ -133,10 +127,14 @@ public class VersionControlDialog extends DialogComponentProvider { /* * Disable the check box for "keep checked out" because some files are still in use. + * @param enabled true if checkbox control should be enabled, false if disabled + * @param selected true if default state should be selected, else not-selected + * @param disabledMsg tooltip message if enabled is false, otherwise ignored. */ - public void setKeepCheckboxEnabled(boolean enabled) { + public void setKeepCheckboxEnabled(boolean enabled, boolean selected, String disabledMsg) { keepCB.setEnabled(enabled); - keepCB.setToolTipText(enabled ? "" : "Must keep Checked Out because the file is in use"); + keepCB.setSelected(selected); + keepCB.setToolTipText(enabled ? "" : disabledMsg); } private JPanel buildMainPanel() { diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/VersionControlTask.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/VersionControlTask.java index 1a424a16c3..9e21bbd4f3 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/VersionControlTask.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/datatree/VersionControlTask.java @@ -58,13 +58,21 @@ public abstract class VersionControlTask extends Task { * @param addToVersionControl true if the dialog is for * adding files to version control, false for checking in files. * @param filename the name of the file currently to be added, whose comment we need. + * @param isLinkFile true if file is a link file, else false. Link-files may not be checked-out + * so keep-checked-out control disabled if this is true. */ - protected void showDialog(boolean addToVersionControl, String filename) { + protected void showDialog(boolean addToVersionControl, String filename, boolean isLinkFile) { Runnable r = () -> { VersionControlDialog vcDialog = new VersionControlDialog(addToVersionControl); vcDialog.setCurrentFileName(filename); vcDialog.setMultiFiles(list.size() > 1); - vcDialog.setKeepCheckboxEnabled(!filesInUse); + if (isLinkFile) { + vcDialog.setKeepCheckboxEnabled(false, false, "Link files may not be Checked Out"); + } + else if (filesInUse) { + vcDialog.setKeepCheckboxEnabled(false, true, + "Must keep Checked Out because the file is in use"); + } actionID = vcDialog.showDialog(tool, parent); keepCheckedOut = vcDialog.keepCheckedOut(); createKeep = vcDialog.shouldCreateKeepFile(); diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/ProjectDataCopyAction.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/ProjectDataCopyAction.java index 486cfa50d0..665558b33a 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/ProjectDataCopyAction.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/ProjectDataCopyAction.java @@ -50,10 +50,6 @@ public class ProjectDataCopyAction extends ProjectDataCopyCutBaseAction { return false; } - if (!context.isInActiveProject()) { - return false; - } - return !context.containsRootFolder(); } } 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 0b79f2e174..12cd31839a 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 @@ -52,6 +52,11 @@ public class ProjectDataNewFolderAction return (context.getFolderCount() + context.getFileCount()) == 1; } + @Override + protected boolean isEnabledForContext(T context) { + return getFolder(context).isInWritableProject(); + } + private void createNewFolder(T context) { DomainFolder parentFolder = getFolder(context); diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/ProjectDataPasteLinkAction.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/ProjectDataPasteLinkAction.java new file mode 100644 index 0000000000..abaee7b9ff --- /dev/null +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/ProjectDataPasteLinkAction.java @@ -0,0 +1,147 @@ +/* ### + * 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.projectdata.actions; + +import java.io.IOException; +import java.util.List; + +import javax.swing.Icon; + +import docking.action.MenuData; +import docking.widgets.tree.GTreeNode; +import ghidra.framework.data.LinkHandler; +import ghidra.framework.main.datatable.ProjectTreeAction; +import ghidra.framework.main.datatree.*; +import ghidra.framework.model.DomainFile; +import ghidra.framework.model.DomainFolder; +import ghidra.util.Msg; +import resources.MultiIcon; +import resources.ResourceManager; + +public class ProjectDataPasteLinkAction extends ProjectTreeAction { + private static Icon baseIcon = ResourceManager.loadImage("images/page_paste.png"); + + public ProjectDataPasteLinkAction(String owner, String group) { + super("Paste Link", owner); + setPopupMenuData(new MenuData(new String[] { "Paste as Link" }, getIcon(), group)); + } + + private static Icon getIcon() { + MultiIcon multiIcon = new MultiIcon(baseIcon); + multiIcon.addIcon(LinkHandler.LINK_ICON); + return multiIcon; + } + + @Override + protected void actionPerformed(FrontEndProjectTreeContext context) { + GTreeNode node = (GTreeNode) context.getContextObject(); + DomainFolderNode destNode = getFolderForNode(node); + if (!isEnabledForContext(context)) { + Msg.showWarn(getClass(), context.getTree(), "Unsupported Operation", + "Unsupported paste link condition"); + } + + GTreeNode copyNode = getFolderOrFileCopyNode(); + if (copyNode instanceof DomainFileNode) { + try { + DomainFile domainFile = ((DomainFileNode) copyNode).getDomainFile(); + domainFile.copyToAsLink(destNode.getDomainFolder()); + } + catch (IOException e) { + Msg.showError(getClass(), context.getTree(), "Cannot Create Link", + "Error occured while creating link file", e); + } + } + else { + try { + DomainFolder domainFolder = ((DomainFolderNode) copyNode).getDomainFolder(); + domainFolder.copyToAsLink(destNode.getDomainFolder()); + } + catch (IOException e) { + Msg.showError(getClass(), context.getTree(), "Cannot Create Link", + "Error occured while creating link file", e); + } + } + + } + + @Override + protected boolean isEnabledForContext(FrontEndProjectTreeContext context) { + if (!context.hasExactlyOneFileOrFolder()) { + return false; + } + if (!context.isInActiveProject()) { + return false; + } + GTreeNode node = (GTreeNode) context.getContextObject(); + DomainFolderNode destNode = getFolderForNode(node); + + GTreeNode copyNode = getFolderOrFileCopyNode(); + if (copyNode == null || copyNode.getParent() == null) { + return false; + } + + // local internal linking not supported + if (destNode.getRoot() == copyNode.getRoot()) { + return false; + } + + if (copyNode instanceof DomainFileNode) { + DomainFile df = ((DomainFileNode) copyNode).getDomainFile(); + return df.isLinkingSupported(); + } + return true; + } + + @Override + protected boolean isAddToPopup(FrontEndProjectTreeContext context) { + if (!context.hasOneOrMoreFilesAndFolders()) { + return false; + } + if (!context.isInActiveProject()) { + return false; + } + GTreeNode copyNode = getFolderOrFileCopyNode(); + return copyNode != null && copyNode.getParent() != null; + } + + private DomainFolderNode getFolderForNode(GTreeNode node) { + if (node instanceof DomainFolderNode) { + return (DomainFolderNode) node; + } + return (DomainFolderNode) node.getParent(); + } + + private GTreeNode getFolderOrFileCopyNode() { + List list = DataTreeClipboardUtils.getDataTreeNodesFromClipboard(); + if (list.size() != 1) { + return null; + } + GTreeNode copyNode = list.get(0); + if (copyNode instanceof DomainFileNode) { + if (!((DomainFileNode) copyNode).isCut()) { + return copyNode; + } + } + if (copyNode instanceof DomainFolderNode) { + if (!((DomainFolderNode) copyNode).isCut()) { + return copyNode; + } + } + return null; + } + +} diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/VersionControlAddAction.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/VersionControlAddAction.java index 3352d4480d..d89d1637c6 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/VersionControlAddAction.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/VersionControlAddAction.java @@ -79,7 +79,7 @@ public class VersionControlAddAction extends VersionControlAction { } List unversioned = new ArrayList<>(); for (DomainFile domainFile : domainFiles) { - if (domainFile.isVersionControlSupported() && !domainFile.isVersioned()) { + if (domainFile.canAddToRepository()) { unversioned.add(domainFile); } } @@ -143,7 +143,7 @@ public class VersionControlAddAction extends VersionControlAction { monitor.setMessage("Adding " + name + " to Version Control"); if (actionID != VersionControlDialog.APPLY_TO_ALL) { - showDialog(true, name); + showDialog(true, name, df.isLinkFile()); } if (actionID == VersionControlDialog.CANCEL) { return; diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/VersionControlCheckInAction.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/VersionControlCheckInAction.java index d4d64548c0..af05b4b60d 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/VersionControlCheckInAction.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/projectdata/actions/VersionControlCheckInAction.java @@ -69,7 +69,7 @@ public class VersionControlCheckInAction extends VersionControlAction { List domainFiles = context.getSelectedFiles(); for (DomainFile domainFile : domainFiles) { - if (domainFile.isCheckedOut() && domainFile.modifiedSinceCheckout()) { + if (domainFile.modifiedSinceCheckout()) { return true; // At least one checked out file selected. } } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/DomainFile.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/DomainFile.java index a012198a29..b5a01829ef 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/DomainFile.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/DomainFile.java @@ -16,17 +16,19 @@ package ghidra.framework.model; import java.io.*; +import java.net.URL; import java.util.List; import java.util.Map; import javax.swing.Icon; import ghidra.framework.client.NotConnectedException; -import ghidra.framework.data.CheckinHandler; +import ghidra.framework.data.*; import ghidra.framework.store.*; import ghidra.util.InvalidNameException; import ghidra.util.exception.*; import ghidra.util.task.TaskMonitor; +import resources.ResourceManager; /** * DomainFile provides a storage interface for project files. A @@ -36,6 +38,9 @@ import ghidra.util.task.TaskMonitor; */ public interface DomainFile extends Comparable { + public static final Icon UNSUPPORTED_FILE_ICON = + ResourceManager.loadImage("images/unknownFile.gif"); + /** * Use with getDomainObject to request the default version. The default version is * the private file or check-out file if one exists, or the latest version from the @@ -74,7 +79,7 @@ public interface DomainFile extends Comparable { * @throws DuplicateFileException if a file named newName * already exists in this files domain folder. * @throws FileInUseException if this file is in-use / checked-out. - * @throws IOException thrown if an IO or access error occurs. + * @throws IOException if an IO or access error occurs. */ public DomainFile setName(String newName) throws InvalidNameException, IOException; @@ -84,6 +89,14 @@ public interface DomainFile extends Comparable { */ public String getPathname(); + /** + * Get a remote Ghidra URL for this domain file if available within the associated shared + * project repository. A null value will be returned if shared file does not exist and + * may be returned if shared repository is not connected or a connection error occurs. + * @return remote Ghidra URL for this file or null + */ + public URL getSharedProjectURL(); + /** * Returns the local storage location for the project that this DomainFile belongs to. * @return the location @@ -92,13 +105,14 @@ public interface DomainFile extends Comparable { /** * Returns content-type string - * @return the content type + * @return the file content type or a reserved content type {@link ContentHandler#MISSING_CONTENT} + * or {@link ContentHandler#UNKNOWN_CONTENT}. */ public String getContentType(); /** * Returns the underlying Class for the domain object in this domain file. - * @return the class + * @return the class or null if does not correspond to a domain object. */ public Class getDomainObjectClass(); @@ -138,7 +152,7 @@ public interface DomainFile extends Comparable { * that the domain object cannot be upgraded to the current format. If okToUpgrade is false, * then the VersionException only means the object is not in the current format - it * may or may not be possible to upgrade. - * @throws IOException thrown if an IO or access error occurs. + * @throws IOException if an IO or access error occurs. * @throws CancelledException if monitor cancelled operation */ public DomainObject getDomainObject(Object consumer, boolean okToUpgrade, boolean okToRecover, @@ -166,7 +180,7 @@ public interface DomainFile extends Comparable { * @throws VersionException if the domain object could not be read due * to a version format change. * @throws FileNotFoundException if the stored file/version was not found. - * @throws IOException thrown if an IO or access error occurs. + * @throws IOException if an IO or access error occurs. * @throws CancelledException if monitor cancelled operation */ public DomainObject getReadOnlyDomainObject(Object consumer, int version, TaskMonitor monitor) @@ -184,7 +198,7 @@ public interface DomainFile extends Comparable { * @throws VersionException if the domain object could not be read due * to a version format change. * @throws FileNotFoundException if the stored file/version was not found. - * @throws IOException thrown if an IO or access error occurs. + * @throws IOException if an IO or access error occurs. * @throws CancelledException if monitor cancelled operation */ public DomainObject getImmutableDomainObject(Object consumer, int version, TaskMonitor monitor) @@ -195,7 +209,7 @@ public interface DomainFile extends Comparable { * @param monitor monitor for the task that is doing the save on the file * @throws FileInUseException if the file is open for update by someone else, or * a transient-read is in progress. - * @throws IOException thrown if an IO error occurs. + * @throws IOException if an IO error occurs. * @throws CancelledException if monitor cancelled operation */ public void save(TaskMonitor monitor) throws IOException, CancelledException; @@ -291,7 +305,7 @@ public interface DomainFile extends Comparable { * for private files (i.e., not versioned). * @param state if true file will be read-only and may not be updated, if false the * file may be updated. - * @throws IOException thrown if an IO error occurs. + * @throws IOException if an IO error occurs. */ public void setReadOnly(boolean state) throws IOException; @@ -302,12 +316,6 @@ public interface DomainFile extends Comparable { */ public boolean isReadOnly(); - /** - * Returns true if the versioned filesystem can be used to store this files content type. - * @return true if supports version control - */ - public boolean isVersionControlSupported(); - /** * Return true if this is a versioned database, else false * @return true if versioned @@ -352,7 +360,7 @@ public interface DomainFile extends Comparable { * @param keepCheckedOut if true, the file will be initially checked-out * @param monitor progress monitor * @throws FileInUseException if this file is in-use. - * @throws IOException thrown if an IO or access error occurs. Also thrown if file is not + * @throws IOException if an IO or access error occurs. Also if file is not * private. * @throws CancelledException if the monitor cancelled the operation */ @@ -367,7 +375,7 @@ public interface DomainFile extends Comparable { * @return true if checkout successful, false if an exclusive checkout was not possible * due to other users having checkouts of this file. A request for a non-exclusive checkout * will never return false. - * @throws IOException thrown if an IO or access error occurs. + * @throws IOException if an IO or access error occurs. * @throws CancelledException if task monitor cancelled operation. */ public boolean checkout(boolean exclusive, TaskMonitor monitor) @@ -406,7 +414,7 @@ public interface DomainFile extends Comparable { * extension. * @throws NotConnectedException if shared project and not connected to repository * @throws FileInUseException if this file is in-use / checked-out. - * @throws IOException thrown if file is not checked-out or an IO / access error occurs. + * @throws IOException if file is not checked-out or an IO / access error occurs. */ public void undoCheckout(boolean keep) throws IOException; @@ -451,7 +459,7 @@ public interface DomainFile extends Comparable { * Delete the entire database for this file, including any version files. * @throws FileInUseException if this file is in-use / checked-out. * @throws UserAccessException if the user does not have permission to delete the file. - * @throws IOException thrown if an IO or access error occurs. + * @throws IOException if an IO or access error occurs. */ public void delete() throws IOException; @@ -476,7 +484,7 @@ public interface DomainFile extends Comparable { * @throws DuplicateFileException if a file with the same name * already exists in newParent folder. * @throws FileInUseException if this file is in-use / checked-out. - * @throws IOException thrown if an IO or access error occurs. + * @throws IOException if an IO or access error occurs. */ public DomainFile moveTo(DomainFolder newParent) throws IOException; @@ -486,7 +494,7 @@ public interface DomainFile extends Comparable { * @param monitor task monitor * @return newly created domain file * @throws FileInUseException if this file is in-use / checked-out. - * @throws IOException thrown if an IO or access error occurs. + * @throws IOException if an IO or access error occurs. * @throws CancelledException if task monitor cancelled operation. */ public DomainFile copyTo(DomainFolder newParent, TaskMonitor monitor) @@ -498,12 +506,34 @@ public interface DomainFile extends Comparable { * @param destFolder destination parent folder * @param monitor task monitor * @return the copied file - * @throws IOException thrown if an IO or access error occurs. + * @throws IOException if an IO or access error occurs. * @throws CancelledException if task monitor cancelled operation. */ public DomainFile copyVersionTo(int version, DomainFolder destFolder, TaskMonitor monitor) throws IOException, CancelledException; + /** + * Copy this file into the newParent folder as a link file. Restrictions: + *

      + *
    • Specified newParent must reside within a different project since internal linking is + * not currently supported.
    • + *
    • Content type must support linking (see {@link #isLinkingSupported()}).
    • + *
    + * If this file is associated with a temporary transient project (i.e., not a locally + * managed project) the generated link will refer to the remote file with a remote + * Ghidra URL, otherwise a local project storage path will be used. + * @param newParent new parent folder + * @return newly created domain file or null if content type does not support link use. + * @throws IOException if an IO or access error occurs. + */ + public DomainFile copyToAsLink(DomainFolder newParent) throws IOException; + + /** + * Determine if this file's content type supports linking. + * @return true if linking is supported, else false. + */ + public boolean isLinkingSupported(); + /** * Get the list of consumers (Objects) for this domain file. * @return empty array list if there are no consumers @@ -552,8 +582,30 @@ public interface DomainFile extends Comparable { * used for storing this file, but does not account for additional storage space * used to tracks changes, etc. * @return file length - * @throws IOException thrown if IO or access error occurs + * @throws IOException if IO or access error occurs */ public long length() throws IOException; + /** + * Determine if this file is a link file which corresponds to either a file or folder link. + * The {@link DomainObject} referenced by a link-file may be opened using + * {@link #getReadOnlyDomainObject(Object, int, TaskMonitor)}. The + * {@link #getDomainObject(Object, boolean, boolean, TaskMonitor)} method may also be used + * to obtain a read-only instance. {@link #getImmutableDomainObject(Object, int, TaskMonitor)} + * use is not supported. + * If the link-file content type equals {@value FolderLinkContentHandler#FOLDER_LINK_CONTENT_TYPE} + * the method {@link #followLink()} can be used to get the linked domain folder. + * The associated link URL may be obtained with {@link LinkHandler#getURL(DomainFile)}. + * The content type (see {@link #getContentType()} of a link file will differ from that of the + * linked object (e.g., "LinkedProgram" vs "Program"). + * @return true if link file else false for a normal domain file + */ + public boolean isLinkFile(); + + /** + * If this is a folder-link file get the corresponding linked folder. + * @return a linked domain folder or null if not a folder-link. + */ + public DomainFolder followLink(); + } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/DomainFileFilter.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/DomainFileFilter.java index f57d3b4569..9ceb871e8f 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/DomainFileFilter.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/DomainFileFilter.java @@ -1,6 +1,5 @@ /* ### * IP: GHIDRA - * REVIEWED: YES * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,4 +30,13 @@ public interface DomainFileFilter { * */ public boolean accept(DomainFile df); + + /** + * Determine if linked folders represented by a link-file should be followed. + * If this method is not implemented the default will return {@code true}. + * @return true if linked-folders should be followed or false to ignore. + */ + public default boolean followLinkedFolders() { + return true; + } } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/DomainFolder.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/DomainFolder.java index 61f43881f8..18ced6cd51 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/DomainFolder.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/DomainFolder.java @@ -17,7 +17,11 @@ package ghidra.framework.model; import java.io.File; import java.io.IOException; +import java.net.URL; +import javax.swing.Icon; + +import generic.theme.GIcon; import ghidra.framework.store.FolderNotEmptyException; import ghidra.util.InvalidNameException; import ghidra.util.exception.*; @@ -30,6 +34,13 @@ import ghidra.util.task.TaskMonitor; * referenced project folder. */ public interface DomainFolder extends Comparable { + + public static final Icon OPEN_FOLDER_ICON = + new GIcon("icon.datatree.node.domain.folder.open"); + + public static final Icon CLOSED_FOLDER_ICON = + new GIcon("icon.datatree.node.domain.folder.closed"); + /** * Character used to separate folder and item names within a path string. */ @@ -78,6 +89,14 @@ public interface DomainFolder extends Comparable { */ public String getPathname(); + /** + * Get a remote Ghidra URL for this domain folder within the associated shared + * project repository. URL path will end with "/". A null value will be returned if not + * associated with a shared project. + * @return remote Ghidra URL for this folder or null + */ + public URL getSharedProjectURL(); + /** * Returns true if this file is in a writable project. * @return true if writable @@ -92,7 +111,7 @@ public interface DomainFolder extends Comparable { /** * Get DomainFolders in this folder. - * This returns cached information and does not force a full refresh. + * This may return cached information and does not force a full refresh. * @return list of sub-folders */ public DomainFolder[] getFolders(); @@ -119,7 +138,7 @@ public interface DomainFolder extends Comparable { /** * Get all domain files in this folder. - * This returns cached information and does not force a full refresh. + * This may return cached information and does not force a full refresh. * @return list of domain files */ public DomainFile[] getFiles(); @@ -203,8 +222,31 @@ public interface DomainFolder extends Comparable { public DomainFolder copyTo(DomainFolder newParent, TaskMonitor monitor) throws IOException, CancelledException; + /** + * Copy this folder into the newParent folder as a link file. Restrictions: + *
      + *
    • Specified newParent must reside within a different project since internal linking is + * not currently supported.
    • + *
    + * If this folder is associated with a temporary transient project (i.e., not a locally + * managed project) the generated link will refer to the remote file with a remote + * Ghidra URL, otherwise a local project storage path will be used. + * @param newParent new parent folder + * @return newly created domain file or null if link use not supported. + * @throws IOException if an IO or access error occurs. + */ + public DomainFile copyToAsLink(DomainFolder newParent) throws IOException; + /** * Allows the framework to react to a request to make this folder the "active" one. */ public void setActive(); + + /** + * Determine if this folder corresponds to a linked-folder. + * @return true if folder corresponds to a linked-folder, else false. + */ + public default boolean isLinked() { + return false; + } } 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 new file mode 100644 index 0000000000..dab005fc34 --- /dev/null +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/LinkedDomainFile.java @@ -0,0 +1,33 @@ +/* ### + * 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.model; + +import java.io.IOException; + +/** + * {@code LinkedDomainFile} corresponds to a {@link DomainFile} contained within a + * {@link LinkedDomainFolder}. + */ +public interface LinkedDomainFile extends DomainFile { + + /** + * Get the real domain file which corresponds to this file contained within a linked-folder. + * @return domain file + * @throws IOException if IO error occurs or file not found + */ + public DomainFile getLinkedFile() throws IOException; + +} diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/LinkedDomainFolder.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/LinkedDomainFolder.java new file mode 100644 index 0000000000..5ea918735e --- /dev/null +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/LinkedDomainFolder.java @@ -0,0 +1,44 @@ +/* ### + * 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.model; + +import java.io.IOException; + +import javax.swing.Icon; + +import ghidra.framework.data.FolderLinkContentHandler; + +/** + * {@code LinkedDomainFolder} extends {@link DomainFolder} for all folders which are + * accessable via a folder-link (see {@link FolderLinkContentHandler}). + */ +public interface LinkedDomainFolder extends DomainFolder { + + /** + * Get the real domain folder which corresponds to this linked-folder. + * @return domain folder + * @throws IOException if an IO error occurs + */ + public DomainFolder getLinkedFolder() throws IOException; + + /** + * Get the appropriate icon for this folder + * @param isOpen true if open icon, false for closed + * @return folder icon + */ + public Icon getIcon(boolean isOpen); + +} diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/Project.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/Project.java index 5f7c8c207a..c5b4bbc62b 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/Project.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/Project.java @@ -16,7 +16,6 @@ package ghidra.framework.model; import java.io.IOException; -import java.net.MalformedURLException; import java.net.URL; import java.util.List; @@ -84,12 +83,14 @@ public interface Project { * Add the given project URL to this project's list project views. * The project view allows users to look at data files from another * project. - * @param projectURL identifier for the project view + * @param projectURL identifier for the project view (ghidra protocol only). + * @param visible true if project may be made visible or false if hidden. Hidden viewed + * projects are used when only life-cycle management is required (e.g., close view project + * when this project is closed). * @return project data for this view * @throws IOException if I/O error occurs or if project/repository not found - * @throws MalformedURLException if projectURL is invalid */ - public ProjectData addProjectView(URL projectURL) throws IOException, MalformedURLException; + public ProjectData addProjectView(URL projectURL, boolean visible) throws IOException; /** * Remove the project view from this project. @@ -98,7 +99,7 @@ public interface Project { public void removeProjectView(URL projectURL); /** - * Return the list of project views in this project. + * Return the list of visible project views in this project. */ public ProjectLocator[] getProjectViews(); @@ -174,9 +175,9 @@ public interface Project { public ProjectData getProjectData(URL url); /** - * Get the project data for other projects that are - * currently being viewed. - * @return zero length array if there are no viewed projects open + * Get the project data for visible viewed projects that are + * managed by this project. + * @return zero length array if there are no visible viewed projects open */ public ProjectData[] getViewedProjectData(); @@ -186,4 +187,16 @@ public interface Project { */ public void releaseFiles(Object consumer); + /** + * Add a listener to be notified when a visible project view is added or removed. + * @param listener project view listener + */ + public void addProjectViewListener(ProjectViewListener listener); + + /** + * Remove a project view listener previously added. + * @param listener project view listener + */ + public void removeProjectViewListener(ProjectViewListener listener); + } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/ProjectData.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/ProjectData.java index a6c0f41aed..fb635f8739 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/ProjectData.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/ProjectData.java @@ -39,6 +39,7 @@ public interface ProjectData { /** * Returns the root folder of the project. + * @return root {@link DomainFolder} within project. */ public DomainFolder getRootFolder(); @@ -110,15 +111,6 @@ public interface ProjectData { */ public DomainFile getFileByID(String fileID); - /** - * Get a URL for a shared domain file which is available - * within a remote repository. - * @param path the absolute path of domain file relative to the root folder. - * @return URL object for accessing shared file from outside of a project, or - * null if file does not exist or is not shared. - */ - public URL getSharedFileURL(String path); - /** * Transform the specified name into an acceptable folder or file item name. Only an individual folder * or file name should be specified, since any separators will be stripped-out. @@ -130,6 +122,7 @@ public interface ProjectData { /** * Returns the projectLocator for the this ProjectData. + * @return project locator object */ public ProjectLocator getProjectLocator(); @@ -150,12 +143,14 @@ public interface ProjectData { * Sync the Domain folder/file structure with the underlying file structure. * @param force if true all folders will be be visited and refreshed, if false * only those folders previously visited will be refreshed. + * @throws IOException if an IO error occurs */ public void refresh(boolean force) throws IOException; /** * Returns User object associated with remote repository or null if a remote repository * is not used. + * @return current remote user identity or null */ public User getUser(); diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/ProjectLocator.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/ProjectLocator.java index e3313d1702..5845a0900f 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/ProjectLocator.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/ProjectLocator.java @@ -1,6 +1,5 @@ /* ### * IP: GHIDRA - * REVIEWED: YES * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,39 +15,65 @@ */ package ghidra.framework.model; -import ghidra.framework.protocol.ghidra.GhidraURL; - import java.io.File; import java.net.URL; +import org.apache.commons.lang3.StringUtils; + +import ghidra.framework.protocol.ghidra.GhidraURL; + /** * Lightweight descriptor of a local Project storage location. */ public class ProjectLocator { - private static final String PROJECT_FILE_SUFFIX = ".gpr"; - private static final String PROJECT_DIR_SUFFIX = ".rep"; + public static final String PROJECT_FILE_SUFFIX = ".gpr"; + public static final String PROJECT_DIR_SUFFIX = ".rep"; + private static final String LOCK_FILE_SUFFIX = ".lock"; - private String name; - private String location; + private final String name; + private final String location; + private URL url; /** - * Construct a project URL. - * @param path path to parent directory + * Construct a project locator object. + * @param path path to parent directory (may or may not exist). The user's temp directory + * will be used if this value is null or blank. + * WARNING: Use of a relative paths should be avoided (e.g., on a windows platform + * an absolute path should start with a drive letter specification such as C:\path, + * while this same path on a Linux platform would be treated as relative). * @param name name of the project */ public ProjectLocator(String path, String name) { - this.name = name; if (name.endsWith(PROJECT_FILE_SUFFIX)) { - this.name = name.substring(0, name.length() - PROJECT_FILE_SUFFIX.length()); + name = name.substring(0, name.length() - PROJECT_FILE_SUFFIX.length()); } - this.location = path; - if (path == null) { - this.location = System.getProperty("java.io.tmpdir"); + this.name = name; + if (StringUtils.isBlank(path)) { + path = System.getProperty("java.io.tmpdir"); } - this.url = GhidraURL.makeURL(location, name); + this.location = checkAbsolutePath(path); + url = GhidraURL.makeURL(location, name); + } + + /** + * Ensure that absolute path is specified. + * @param path path to be checked and possibly modified. + * @return path to be used + */ + private static String checkAbsolutePath(String path) { + if (path.startsWith("/") && path.length() >= 4 && path.indexOf(":/") == 2 && + Character.isLetter(path.charAt(1))) { + // strip leading "/" on Windows paths (e.g., /C:/mydir) and transform separators to '\' + path = path.substring(1); + path = path.replace('/', '\\'); + } + if (path.endsWith("/") || path.endsWith("\\")) { + path = path.substring(0, path.length() - 1); + } + return path; } /** @@ -60,8 +85,8 @@ public class ProjectLocator { } /** - * Returns the URL associated with this local project. - * If this is a transient project, a remote repository URL will be returned. + * Returns the URL associated with this local project. If using a temporary transient + * project location this URL should not be used. */ public URL getURL() { return url; @@ -75,7 +100,10 @@ public class ProjectLocator { } /** - * Get the location of the project. + * Get the location of the project which will contain marker file + * ({@link #getMarkerFile()}) and project directory ({@link #getProjectDir()}). + * Note: directory may or may not exist. + * @return project location directory */ public String getLocation() { return location; @@ -110,9 +138,6 @@ public class ProjectLocator { return PROJECT_DIR_SUFFIX; } - /* (non-Javadoc) - * @see java.lang.Object#equals(java.lang.Object) - */ @Override public boolean equals(Object obj) { if (obj == null) { @@ -125,23 +150,14 @@ public class ProjectLocator { return false; } ProjectLocator projectLocator = (ProjectLocator) obj; - if (hashCode() != projectLocator.hashCode()) { - return false; - } return name.equals(projectLocator.name) && location.equals(projectLocator.location); } - /* (non-Javadoc) - * @see java.lang.Object#hashCode() - */ @Override public int hashCode() { return name.hashCode() + location.hashCode(); } - /* (non-Javadoc) - * @see java.lang.Object#toString() - */ @Override public String toString() { return GhidraURL.getDisplayString(url); diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/ProjectViewListener.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/ProjectViewListener.java new file mode 100644 index 0000000000..9a65ec9ef8 --- /dev/null +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/ProjectViewListener.java @@ -0,0 +1,42 @@ +/* ### + * 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.model; + +import java.net.URL; + +/** + * {@code ProjectViewListener} provides a listener interface for tracking project views added + * and removed from the associated project. + *
    + * NOTE: notification callbacks are not guarenteed to occur within the swing thread. + */ +public interface ProjectViewListener { + + /** + * Provides notification that a read-only viewed project has been added which is intended to + * be visible. Notification for hidden viewed projects will not be provided. + * @param projectView project view URL + */ + void viewedProjectAdded(URL projectView); + + /** + * Provides notification that a viewed project is being removed from the project. + * Notification for hidden viewed project removal will not be provided. + * @param projectView project view URL + */ + void viewedProjectRemoved(URL projectView); + +} diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/ToolServices.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/ToolServices.java index c4b07d6fe5..11b2dd12e6 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/ToolServices.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/model/ToolServices.java @@ -16,10 +16,12 @@ package ghidra.framework.model; import java.io.*; +import java.net.URL; import java.util.Set; import ghidra.framework.plugintool.PluginEvent; import ghidra.framework.plugintool.PluginTool; +import ghidra.framework.protocol.ghidra.GhidraURL; /** * Services that the Tool uses. @@ -73,15 +75,23 @@ public interface ToolServices { public void displaySimilarTool(PluginTool tool, DomainFile domainFile, PluginEvent event); /** - * Returns the default tool template used to open the tool. Here default means the - * tool that should be used to open the given file, whether defined by the user or the - * system default. + * Returns the default/preferred tool template which should be used to open the specified + * domain file, whether defined by the user or the system default. * - * @param domainFile The file for which to find the preferred tool. - * @return The preferred tool that should be used to open the given file. + * @param domainFile The file whose preferred tool should be found. + * @return The preferred tool that should be used to open the given file or null if none found. */ public ToolTemplate getDefaultToolTemplate(DomainFile domainFile); + /** + * Returns the default/preferred tool template which should be used to open the specified + * domain file content type, whether defined by the user or the system default. + * + * @param contentType The content type whose preferred tool should be found. + * @return The preferred tool that should be used to open the given file or null if none found. + */ + public ToolTemplate getDefaultToolTemplate(String contentType); + /** * Returns a set of tools that can open the given domain file class. * @param domainClass The domain file class type for which to get tools @@ -108,21 +118,43 @@ public interface ToolServices { public void setContentTypeToolAssociations(Set infos); /** - * Launch the default tool; if domainFile is not null, this file will - * be opened in the tool. - * @param domainFile the file to open; may be null - * @return the tool + * Launch the default tool and open the specified domainFile. + * @param domainFile the file to open + * @return the launched tool. Null returned if a suitable default tool + * for the file content type was not found. */ public PluginTool launchDefaultTool(DomainFile domainFile); /** - * Launch the tool with the given name + * Launch the tool with the given name. A domainFile may be specified and will be opened + * if its content type is supported by the tool. * @param toolName name of the tool to launch * @param domainFile the file to open; may be null - * @return the tool + * @return the requested tool or null if the specified tool not found. */ public PluginTool launchTool(String toolName, DomainFile domainFile); + /** + * Launch the default tool and open the specified Ghidra URL resource. + * The tool choosen well be based upon the content type of the specified resource. + * @param ghidraUrl resource to be opened (see {@link GhidraURL}) + * @return the launched tool. Null returned if a failure occurs while accessing the specified + * resource or a suitable default tool for the file content type was not found. + * @throws IllegalArgumentException if URL protocol is not supported. Currently, only + * the {@code ghidra} protocol is supported. + */ + public PluginTool launchDefaultToolWithURL(URL ghidraUrl); + + /** + * Launch the tool with the given name and attempt to open the specified Ghidra URL resource. + * @param toolName name of the tool to launch + * @param ghidraUrl resource to be opened (see {@link GhidraURL}) + * @return the requested tool or null if the specified tool not found. + * @throws IllegalArgumentException if URL protocol is not supported. Currently, only + * the {@code ghidra} protocol is supported. + */ + public PluginTool launchToolWithURL(String toolName, URL ghidraUrl); + /** * Add a listener that will be notified when the default tool specification changes * @param listener the listener diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/Plugin.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/Plugin.java index 3b67a286b4..3efd06adc5 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/Plugin.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/Plugin.java @@ -15,6 +15,7 @@ */ package ghidra.framework.plugintool; +import java.net.URL; import java.util.*; import docking.ComponentProvider; @@ -330,6 +331,17 @@ public abstract class Plugin implements ExtensionPoint, PluginEventListener, Ser return false; } + /** + * Request plugin to process URL if supported. Actual processing may be delayed and + * interaction with user may occur (e.g., authentication, approval, etc.). + *

    + * @param url data URL + * @return boolean true if this plugin can process URL. + */ + public boolean accept(URL url) { + return false; + } + /** * Get the domain files that this plugin has open. *

    diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/PluginManager.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/PluginManager.java index 7308f92f80..6cfae8f1d4 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/PluginManager.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/PluginManager.java @@ -15,6 +15,7 @@ */ package ghidra.framework.plugintool; +import java.net.URL; import java.util.*; import java.util.Map.Entry; @@ -52,6 +53,23 @@ class PluginManager { return false; } + /** + * Identify plugin which will accept specified URL. If no plugin accepts URL it will be + * rejected and false returned. If a plugin can accept the specified URL it will attempt to + * process and return true if successful. + * The user may be prompted if connecting to the URL requires user authentication. + * @param url read-only resource URL + * @return true if URL accepted and processed else false + */ + boolean accept(URL url) { + for (Plugin p : pluginList) { + if (p.accept(url)) { + return true; + } + } + return false; + } + public void dispose() { for (Iterator it = pluginList.iterator(); it.hasNext();) { Plugin plugin = it.next(); diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/PluginTool.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/PluginTool.java index 07fe6bc906..590148b7d5 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/PluginTool.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/PluginTool.java @@ -21,6 +21,7 @@ import java.awt.*; import java.awt.event.KeyEvent; import java.beans.PropertyChangeListener; import java.beans.PropertyChangeSupport; +import java.net.URL; import java.util.*; import java.util.List; import java.util.concurrent.atomic.AtomicReference; @@ -440,6 +441,18 @@ public abstract class PluginTool extends AbstractDockingTool { return pluginMgr.acceptData(data); } + /** + * Request tool to accept specified URL. Acceptance of URL depends greatly on the plugins + * confiugred into tool. If no plugin accepts URL it will be rejected and false returned. + * If a plugin can accept the specified URL it will attempt to process and return true if + * successful. The user may be prompted if connecting to the URL requires user authentication. + * @param url read-only resource URL + * @return true if URL accepted and processed else false + */ + public boolean accept(URL url) { + return pluginMgr.accept(url); + } + public void addPropertyChangeListener(PropertyChangeListener l) { propertyChangeMgr.addPropertyChangeListener(l); diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/ToolServicesAdapter.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/ToolServicesAdapter.java index b865f615e0..91a3f4cf78 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/ToolServicesAdapter.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/plugintool/ToolServicesAdapter.java @@ -16,6 +16,7 @@ package ghidra.framework.plugintool; import java.io.*; +import java.net.URL; import java.util.Set; import ghidra.framework.model.*; @@ -61,6 +62,11 @@ public class ToolServicesAdapter implements ToolServices { return null; } + @Override + public ToolTemplate getDefaultToolTemplate(String contentType) { + return null; + } + @Override public PluginTool[] getRunningTools() { return null; @@ -81,6 +87,16 @@ public class ToolServicesAdapter implements ToolServices { return null; } + @Override + public PluginTool launchDefaultToolWithURL(URL url) { + return null; + } + + @Override + public PluginTool launchToolWithURL(String toolName, URL url) { + return null; + } + @Override public void removeDefaultToolChangeListener(DefaultToolChangeListener listener) { // override diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/DefaultProject.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/DefaultProject.java index 6e53c4e81f..3ea00eebac 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/DefaultProject.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/DefaultProject.java @@ -16,7 +16,6 @@ package ghidra.framework.project; import java.io.*; -import java.net.MalformedURLException; import java.net.URL; import java.util.*; import java.util.Map.Entry; @@ -25,7 +24,6 @@ import org.jdom.*; import org.jdom.input.SAXBuilder; import org.jdom.output.XMLOutputter; -import docking.widgets.OptionDialog; import ghidra.framework.client.RepositoryAdapter; import ghidra.framework.data.ProjectFileManager; import ghidra.framework.data.TransientDataManager; @@ -35,8 +33,11 @@ import ghidra.framework.project.tool.GhidraToolTemplate; import ghidra.framework.project.tool.ToolManagerImpl; import ghidra.framework.protocol.ghidra.GhidraURL; import ghidra.framework.protocol.ghidra.GhidraURLConnection; +import ghidra.framework.protocol.ghidra.GhidraURLConnection.StatusCode; import ghidra.framework.store.LockException; import ghidra.util.*; +import ghidra.util.datastruct.WeakDataStructureFactory; +import ghidra.util.datastruct.WeakSet; import ghidra.util.exception.DuplicateNameException; import ghidra.util.xml.GenericXMLOutputter; import ghidra.util.xml.XmlUtilities; @@ -53,8 +54,6 @@ public class DefaultProject implements Project { private static final String PROJECT_STATE = "projectState"; - private ProjectLock projectLock; - // this may be null private DefaultProjectManager projectManager; @@ -63,11 +62,14 @@ public class DefaultProject implements Project { private ToolManagerImpl toolManager; private boolean changed; // flag for whether the project configuration has changed - private boolean isClosed; + private volatile boolean isClosed; private Map dataMap = new HashMap<>(); - private HashMap projectConfigMap = new HashMap<>(); - private HashMap otherViews = new HashMap<>(); + private Map projectConfigMap = new HashMap<>(); + private Map otherViews = new HashMap<>(); + private Set visibleViews = new HashSet<>(); + private WeakSet viewListeners = + WeakDataStructureFactory.createCopyOnWriteWeakSet(); /** * Constructor for creating a New project @@ -83,10 +85,6 @@ public class DefaultProject implements Project { RepositoryAdapter repository) throws IOException, LockException { this.projectManager = projectManager; this.projectLocator = projectLocator; - this.projectLock = getProjectLock(projectLocator, false); - if (projectLock == null) { - throw new LockException("Unable to lock project! " + projectLocator); - } boolean success = false; try { @@ -102,7 +100,6 @@ public class DefaultProject implements Project { if (fileMgr != null) { fileMgr.dispose(); } - projectLock.release(); } } initializeNewProject(); @@ -124,10 +121,6 @@ public class DefaultProject implements Project { this.projectManager = projectManager; this.projectLocator = projectLocator; - this.projectLock = getProjectLock(projectLocator, true); - if (projectLock == null) { - throw new LockException("Unable to lock project! " + projectLocator); - } boolean success = false; try { @@ -143,7 +136,6 @@ public class DefaultProject implements Project { if (fileMgr != null) { fileMgr.dispose(); } - projectLock.release(); } } } @@ -188,54 +180,64 @@ public class DefaultProject implements Project { return projectManager; } - /** - * Creates a ProjectLock and attempts to lock it. This handles the case - * where the project was previously locked. - * - * @param locator the project locator - * @param allowInteractiveForce if true, when a lock cannot be obtained, the - * user will be prompted - * @return A locked ProjectLock - * @throws ProjectLockException if lock failed - */ - private ProjectLock getProjectLock(ProjectLocator locator, boolean allowInteractiveForce) { - ProjectLock lock = new ProjectLock(locator); - if (lock.lock()) { - return lock; - } +// /** +// * Determine if the specified project location currently has a write lock. +// * @param locator project storage locator +// * @return true if project data current has write-lock else false +// */ +// public static boolean isLocked(ProjectLocator locator) { +// ProjectLock lock = new ProjectLock(locator); +// return lock.isLocked(); +// } - // in headless mode, just spit out an error - if (!allowInteractiveForce || SystemUtilities.isInHeadlessMode()) { - return null; - } - - String projectStr = "Project: " + HTMLUtilities.escapeHTML(locator.getLocation()) + - System.getProperty("file.separator") + HTMLUtilities.escapeHTML(locator.getName()); - String lockInformation = lock.getExistingLockFileInformation(); - if (!lock.canForceLock()) { - Msg.showInfo(getClass(), null, "Project Locked", - "Project is locked. You have another instance of Ghidra
    " + - "already running with this project open (locally or remotely).

    " + - projectStr + "

    " + "Lock information: " + lockInformation); - return null; - } - - int userChoice = OptionDialog.showOptionDialog(null, "Project Locked - Delete Lock?", - "Project is locked. You may have another instance of Ghidra
    " + - "already running with this project opened (locally or remotely).
    " + projectStr + - "

    " + "If this is not the case, you can delete the lock file:
    " + - locator.getProjectLockFile().getAbsolutePath() + ".

    " + - "Lock information: " + lockInformation, - "Delete Lock", OptionDialog.QUESTION_MESSAGE); - if (userChoice == OptionDialog.OPTION_ONE) { // Delete Lock - if (lock.forceLock()) { - return lock; - } - - Msg.showError(this, null, "Error", "Attempt to force lock failed! " + locator); - } - return null; - } +// /** +// * Creates a ProjectLock and attempts to lock it. This handles the case +// * where the project was previously locked. +// * +// * @param locator the project locator +// * @param allowInteractiveForce if true, when a lock cannot be obtained, the +// * user will be prompted +// * @return A locked ProjectLock +// * @throws ProjectLockException if lock failed +// */ +// private ProjectLock getProjectLock(ProjectLocator locator, boolean allowInteractiveForce) { +// ProjectLock lock = new ProjectLock(locator); +// if (lock.lock()) { +// return lock; +// } +// +// // in headless mode, just spit out an error +// if (!allowInteractiveForce || SystemUtilities.isInHeadlessMode()) { +// return null; +// } +// +// String projectStr = "Project: " + HTMLUtilities.escapeHTML(locator.getLocation()) + +// System.getProperty("file.separator") + HTMLUtilities.escapeHTML(locator.getName()); +// String lockInformation = lock.getExistingLockFileInformation(); +// if (!lock.canForceLock()) { +// Msg.showInfo(getClass(), null, "Project Locked", +// "Project is locked. You have another instance of Ghidra
    " + +// "already running with this project open (locally or remotely).

    " + +// projectStr + "

    " + "Lock information: " + lockInformation); +// return null; +// } +// +// int userChoice = OptionDialog.showOptionDialog(null, "Project Locked - Delete Lock?", +// "Project is locked. You may have another instance of Ghidra
    " + +// "already running with this project opened (locally or remotely).
    " + projectStr + +// "

    " + "If this is not the case, you can delete the lock file:
    " + +// locator.getProjectLockFile().getAbsolutePath() + ".

    " + +// "Lock information: " + lockInformation, +// "Delete Lock", OptionDialog.QUESTION_MESSAGE); +// if (userChoice == OptionDialog.OPTION_ONE) { // Delete Lock +// if (lock.forceLock()) { +// return lock; +// } +// +// Msg.showError(this, null, "Error", "Attempt to force lock failed! " + locator); +// } +// return null; +// } private void initializeNewProject() { if (toolManager == null) { @@ -261,54 +263,94 @@ public class DefaultProject implements Project { } @Override - public ProjectData addProjectView(URL url) throws IOException, MalformedURLException { + public void addProjectViewListener(ProjectViewListener listener) { + viewListeners.add(listener); + } - ProjectData pd = otherViews.get(url); - if (pd != null) { - return pd; - } + @Override + public void removeProjectViewListener(ProjectViewListener listener) { + viewListeners.remove(listener); + } - if (!GhidraURL.PROTOCOL.equals(url.getProtocol())) { - throw new IOException("Invalid Ghidra URL specified: " + url); + private void notifyVisibleViewAdded(URL projectView) { + for (ProjectViewListener listener : viewListeners) { + listener.viewedProjectAdded(projectView); } + } + + private void notifyVisibleViewRemoved(URL projectView) { + for (ProjectViewListener listener : viewListeners) { + listener.viewedProjectRemoved(projectView); + } + } + + private ProjectData openProjectView(URL url) throws IOException { GhidraURLConnection c = (GhidraURLConnection) url.openConnection(); c.setAllowUserInteraction(true); c.setReadOnly(true); - int responseCode = c.getResponseCode(); - if (responseCode == GhidraURLConnection.GHIDRA_NOT_FOUND) { + StatusCode responseCode = c.getStatusCode(); + if (responseCode == StatusCode.NOT_FOUND) { throw new IOException( "Project/repository not found: " + GhidraURL.getDisplayString(url)); } - if (responseCode == GhidraURLConnection.GHIDRA_UNAUTHORIZED) { - throw new IOException( - "Authentication Failed for project/repository: " + GhidraURL.getDisplayString(url)); + if (responseCode == StatusCode.UNAUTHORIZED) { + // assume already informed + return null; } ProjectFileManager projectData = (ProjectFileManager) c.getProjectData(); if (projectData == null) { throw new IOException( - "Failed to view specified project/repository: " + GhidraURL.getDisplayString(url)); + "Failed to view specified project/repository: " + + GhidraURL.getDisplayString(url)); } url = projectData.getProjectLocator().getURL(); // transform to repository root URL otherViews.put(url, projectData); - changed = true; - Msg.info(this, "Opened project view: " + GhidraURL.getDisplayString(url)); return projectData; } + @Override + public ProjectData addProjectView(URL url, boolean visible) throws IOException { + synchronized (otherViews) { + if (isClosed) { + throw new IOException("project is closed"); + } + + if (!GhidraURL.PROTOCOL.equals(url.getProtocol())) { + throw new IOException("Invalid Ghidra URL specified: " + url); + } + + ProjectData projectData = otherViews.get(url); + if (projectData == null) { + projectData = openProjectView(url); + } + + if (projectData != null && visible && visibleViews.add(url)) { + notifyVisibleViewAdded(url); + } + + return projectData; + } + } + /** * Remove the view from this project. */ @Override public void removeProjectView(URL url) { - ProjectFileManager dataMgr = otherViews.remove(url); - if (dataMgr != null) { - dataMgr.dispose(); - Msg.info(this, "Closed project view: " + GhidraURL.getDisplayString(url)); - changed = true; + synchronized (otherViews) { + ProjectFileManager dataMgr = otherViews.remove(url); + if (dataMgr != null) { + if (visibleViews.remove(url)) { + notifyVisibleViewRemoved(url); + } + dataMgr.dispose(); + Msg.info(this, "Closed project view: " + GhidraURL.getDisplayString(url)); + changed = true; + } } } @@ -348,6 +390,7 @@ public class DefaultProject implements Project { @Override public ProjectLocator[] getProjectViews() { + // Only includes visible viewed projects ProjectData[] pd = getViewedProjectData(); ProjectLocator[] views = new ProjectLocator[pd.length]; @@ -364,17 +407,21 @@ public class DefaultProject implements Project { @Override public void close() { - Iterator iter = otherViews.values().iterator(); - while (iter.hasNext()) { - ProjectFileManager dataMgr = iter.next(); - if (dataMgr != null) { - dataMgr.dispose(); + synchronized (otherViews) { + isClosed = true; + + Iterator iter = otherViews.values().iterator(); + while (iter.hasNext()) { + ProjectFileManager dataMgr = iter.next(); + if (dataMgr != null) { + dataMgr.dispose(); + } } + otherViews.clear(); } - otherViews.clear(); try { - isClosed = true; + if (toolManager != null) { toolManager.close(); toolManager.dispose(); @@ -382,12 +429,9 @@ public class DefaultProject implements Project { if (projectManager != null) { projectManager.projectClosed(this); } - fileMgr.dispose(); } finally { - if (projectLock != null) { - projectLock.release(); - } + fileMgr.dispose(); } } @@ -450,7 +494,7 @@ public class DefaultProject implements Project { String location = elem.getAttributeValue("LOCATION"); URL url = GhidraURL.makeURL(location, name); try { - addProjectView(url); + addProjectView(url, true); } catch (IOException e) { Msg.error(this, e.getMessage()); @@ -462,7 +506,7 @@ public class DefaultProject implements Project { String urlStr = elem.getAttributeValue("URL"); URL url = new URL(urlStr); try { - addProjectView(url); + addProjectView(url, true); } catch (IOException e) { Msg.error(this, e.getMessage()); @@ -602,7 +646,7 @@ public class DefaultProject implements Project { } @Override - public ProjectData getProjectData() { + public ProjectFileManager getProjectData() { return fileMgr; } @@ -622,11 +666,14 @@ public class DefaultProject implements Project { return fileMgr; } - for (ProjectData data : otherViews.values()) { - if (locator.equals(data.getProjectLocator())) { - return data; + synchronized (otherViews) { + for (ProjectData data : otherViews.values()) { + if (locator.equals(data.getProjectLocator())) { + return data; + } } } + return null; } @@ -640,18 +687,31 @@ public class DefaultProject implements Project { @Override public ProjectData[] getViewedProjectData() { - ProjectData[] projectData = new ProjectData[otherViews.size()]; - otherViews.values().toArray(projectData); - return projectData; + synchronized (otherViews) { + + // only return visible viewed project + List list = new ArrayList<>(); + for (URL url : otherViews.keySet()) { + if (visibleViews.contains(url)) { + list.add(otherViews.get(url)); + } + } + + ProjectData[] projectData = new ProjectData[list.size()]; + list.toArray(projectData); + return projectData; + } } @Override public void releaseFiles(Object consumer) { fileMgr.releaseDomainFiles(consumer); - Iterator it = otherViews.values().iterator(); - while (it.hasNext()) { - ProjectFileManager mgr = it.next(); - mgr.releaseDomainFiles(consumer); + synchronized (otherViews) { + Iterator it = otherViews.values().iterator(); + while (it.hasNext()) { + ProjectFileManager mgr = it.next(); + mgr.releaseDomainFiles(consumer); + } } TransientDataManager.releaseFiles(consumer); } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/tool/GhidraToolTemplate.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/tool/GhidraToolTemplate.java index 397680a173..0e63ccb3e1 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/tool/GhidraToolTemplate.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/tool/GhidraToolTemplate.java @@ -152,7 +152,7 @@ public class GhidraToolTemplate implements ToolTemplate { dtList.add(Class.forName(className)); } catch (ClassNotFoundException e) { - Msg.error(this, "Class not found: " + className, e); + Msg.warn(this, "Tool supported content class not found: " + className); } catch (Exception exc) {//TODO Msg.error(this, "Unexpected Exception: " + exc.getMessage(), exc); diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/tool/ToolServicesImpl.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/tool/ToolServicesImpl.java index a78fa72238..cfebe63aee 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/tool/ToolServicesImpl.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/project/tool/ToolServicesImpl.java @@ -16,6 +16,7 @@ package ghidra.framework.project.tool; import java.io.*; +import java.net.URL; import java.util.*; import org.jdom.Document; @@ -24,16 +25,18 @@ import org.jdom.output.XMLOutputter; import docking.widgets.OptionDialog; import docking.widgets.filechooser.GhidraFileChooser; import ghidra.framework.ToolUtils; -import ghidra.framework.data.ContentHandler; -import ghidra.framework.data.DomainObjectAdapter; +import ghidra.framework.data.*; import ghidra.framework.model.*; import ghidra.framework.plugintool.PluginEvent; import ghidra.framework.plugintool.PluginTool; import ghidra.framework.preferences.Preferences; +import ghidra.framework.protocol.ghidra.GetUrlContentTypeTask; +import ghidra.framework.protocol.ghidra.GhidraURL; import ghidra.util.Msg; import ghidra.util.classfinder.ClassSearcher; import ghidra.util.filechooser.GhidraFileChooserModel; import ghidra.util.filechooser.GhidraFileFilter; +import ghidra.util.task.TaskLauncher; import ghidra.util.xml.GenericXMLOutputter; /** @@ -48,7 +51,7 @@ class ToolServicesImpl implements ToolServices { private ToolManagerImpl toolManager; private List listeners = new ArrayList<>(); private ToolChestChangeListener toolChestChangeListener; - private Set contentHandlers; + private Set> contentHandlers; ToolServicesImpl(ToolChest toolChest, ToolManagerImpl toolManager) { this.toolChest = toolChest; @@ -187,38 +190,88 @@ class ToolServicesImpl implements ToolServices { @Override public PluginTool launchDefaultTool(DomainFile domainFile) { ToolTemplate template = getDefaultToolTemplate(domainFile); - if (template != null) { - Workspace workspace = toolManager.getActiveWorkspace(); - PluginTool tool = workspace.runTool(template); - tool.setVisible(true); - if (domainFile != null) { - tool.acceptDomainFiles(new DomainFile[] { domainFile }); - } - return tool; + if (template == null) { + return null; } - return null; + Workspace workspace = toolManager.getActiveWorkspace(); + PluginTool tool = workspace.runTool(template); + if (tool == null) { + return null; + } + tool.setVisible(true); + tool.acceptDomainFiles(new DomainFile[] { domainFile }); + return tool; } @Override public PluginTool launchTool(String toolName, DomainFile domainFile) { ToolTemplate template = findToolChestToolTemplate(toolName); - if (template != null) { - Workspace workspace = toolManager.getActiveWorkspace(); - PluginTool tool = workspace.runTool(template); - tool.setVisible(true); - if (domainFile != null) { - tool.acceptDomainFiles(new DomainFile[] { domainFile }); - } - return tool; + if (template == null) { + return null; } - return null; + Workspace workspace = toolManager.getActiveWorkspace(); + PluginTool tool = workspace.runTool(template); + if (tool == null) { + return null; + } + tool.setVisible(true); + if (domainFile != null) { + tool.acceptDomainFiles(new DomainFile[] { domainFile }); + } + return tool; + } + + @Override + public PluginTool launchDefaultToolWithURL(URL ghidraUrl) throws IllegalArgumentException { + String contentType = getContentType(ghidraUrl); + if (contentType == null) { + return null; + } + ToolTemplate template = getDefaultToolTemplate(contentType); + if (template == null) { + return null; + } + Workspace workspace = toolManager.getActiveWorkspace(); + PluginTool tool = workspace.runTool(template); + if (tool == null) { + return null; + } + tool.setVisible(true); + tool.accept(ghidraUrl); + return tool; + } + + @Override + public PluginTool launchToolWithURL(String toolName, URL ghidraUrl) + throws IllegalArgumentException { + if (!GhidraURL.isLocalProjectURL(ghidraUrl) && + !GhidraURL.isServerRepositoryURL(ghidraUrl)) { + throw new IllegalArgumentException("unsupported URL"); + } + ToolTemplate template = findToolChestToolTemplate(toolName); + if (template == null) { + return null; + } + Workspace workspace = toolManager.getActiveWorkspace(); + PluginTool tool = workspace.runTool(template); + if (tool != null) { + tool.setVisible(true); + tool.accept(ghidraUrl); + } + return tool; + } + + private String getContentType(URL url) throws IllegalArgumentException { + GetUrlContentTypeTask task = new GetUrlContentTypeTask(url); + TaskLauncher.launch(task); // blocking task + return task.getContentType(); } @Override public void setContentTypeToolAssociations(Set infos) { for (ToolAssociationInfo info : infos) { - ContentHandler handler = info.getContentHandler(); + ContentHandler handler = info.getContentHandler(); String contentType = handler.getContentType(); String preferenceKey = getToolAssociationPreferenceKey(contentType); if (!info.isDefault()) { @@ -254,15 +307,15 @@ class ToolServicesImpl implements ToolServices { Set set = new HashSet<>(); // get all known content types - Set handlers = getContentHandlers(); - for (ContentHandler contentHandler : handlers) { + Set> handlers = getContentHandlers(); + for (ContentHandler contentHandler : handlers) { set.add(createToolAssociationInfo(contentHandler)); } return set; } - private ToolAssociationInfo createToolAssociationInfo(ContentHandler contentHandler) { + private ToolAssociationInfo createToolAssociationInfo(ContentHandler contentHandler) { String contentType = contentHandler.getContentType(); String defaultToolName = contentHandler.getDefaultToolName(); String userPreferredToolName = @@ -280,8 +333,11 @@ class ToolServicesImpl implements ToolServices { @Override public ToolTemplate getDefaultToolTemplate(DomainFile domainFile) { - String contentType = domainFile.getContentType(); + return getDefaultToolTemplate(domainFile.getContentType()); + } + @Override + public ToolTemplate getDefaultToolTemplate(String contentType) { String toolName = Preferences.getProperty(getToolAssociationPreferenceKey(contentType), null, true); if (toolName == null) { @@ -312,8 +368,8 @@ class ToolServicesImpl implements ToolServices { // // Next, look through for all compatible content handlers find tools for them // - Set compatibleHandlers = getCompatibleContentHandlers(domainClass); - for (ContentHandler handler : compatibleHandlers) { + Set> compatibleHandlers = getCompatibleContentHandlers(domainClass); + for (ContentHandler handler : compatibleHandlers) { String defaultToolName = handler.getDefaultToolName(); if (nameToTemplateMap.get(defaultToolName) != null) { continue; // already have tool in the map by this name; prefer that tool @@ -355,11 +411,11 @@ class ToolServicesImpl implements ToolServices { return new HashSet<>(nameToTemplateMap.values()); } - private Set getCompatibleContentHandlers( + private Set> getCompatibleContentHandlers( Class domainClass) { - Set set = new HashSet<>(); - Set handlers = getContentHandlers(); - for (ContentHandler contentHandler : handlers) { + Set> set = new HashSet<>(); + Set> handlers = getContentHandlers(); + for (ContentHandler contentHandler : handlers) { Class handlerDomainClass = contentHandler.getDomainObjectClass(); if (handlerDomainClass == domainClass) { @@ -374,8 +430,8 @@ class ToolServicesImpl implements ToolServices { } private String getDefaultToolAssociation(String contentType) { - Set handlers = getContentHandlers(); - for (ContentHandler contentHandler : handlers) { + Set> handlers = getContentHandlers(); + for (ContentHandler contentHandler : handlers) { String type = contentHandler.getContentType(); if (type.equals(contentType)) { return contentHandler.getDefaultToolName(); @@ -384,25 +440,31 @@ class ToolServicesImpl implements ToolServices { return null; } - private Set getContentHandlers() { + private Set> getContentHandlers() { if (contentHandlers != null) { return contentHandlers; } contentHandlers = new HashSet<>(); + @SuppressWarnings("rawtypes") List instances = ClassSearcher.getInstances(ContentHandler.class); - for (ContentHandler contentHandler : instances) { + for (ContentHandler contentHandler : instances) { + + if (contentHandler instanceof FolderLinkContentHandler) { + continue; // ignore folder link handler + } + // a bit of validation String contentType = contentHandler.getContentType(); if (contentType == null) { - Msg.error(DomainObjectAdapter.class, "ContentHandler " + + Msg.error(DomainObjectAdapter.class, "ContentHandler " + contentHandler.getClass().getName() + " does not specify a content type"); continue; } - + String toolName = contentHandler.getDefaultToolName(); if (toolName == null) { - Msg.error(DomainObjectAdapter.class, "ContentHandler " + + Msg.error(DomainObjectAdapter.class, "ContentHandler " + contentHandler.getClass().getName() + " does not specify a default tool"); continue; } @@ -479,7 +541,7 @@ class ToolServicesImpl implements ToolServices { private PluginTool findToolUsingFile(PluginTool[] tools, DomainFile domainFile) { PluginTool matchingTool = null; for (int toolNum = 0; (toolNum < tools.length) && (matchingTool == null); toolNum++) { - PluginTool pTool = (PluginTool) tools[toolNum]; + PluginTool pTool = tools[toolNum]; // Is this tool the same as the type we are in. DomainFile[] df = pTool.getDomainFiles(); for (DomainFile element : df) { diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/DefaultGhidraProtocolConnector.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/DefaultGhidraProtocolConnector.java index 8cc721cfab..456329843e 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/DefaultGhidraProtocolConnector.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/DefaultGhidraProtocolConnector.java @@ -19,8 +19,10 @@ import java.io.IOException; import java.net.MalformedURLException; import java.net.URL; -import ghidra.framework.client.ClientUtil; -import ghidra.framework.client.NotConnectedException; +import javax.security.auth.login.LoginException; + +import ghidra.framework.client.*; +import ghidra.framework.protocol.ghidra.GhidraURLConnection.StatusCode; import ghidra.util.Msg; /** @@ -44,36 +46,51 @@ public class DefaultGhidraProtocolConnector extends GhidraProtocolConnector { @Override public boolean isReadOnly() throws NotConnectedException { - if (responseCode == -1) { + if (statusCode == null) { throw new NotConnectedException("not connected"); } return readOnly; } @Override - public int connect(boolean readOnlyAccess) throws IOException { + public StatusCode connect(boolean readOnlyAccess) throws IOException { - if (responseCode != -1) { + if (statusCode != null) { throw new IllegalStateException("already connected"); } this.readOnly = readOnlyAccess; - responseCode = GhidraURLConnection.GHIDRA_NOT_FOUND; // just in case + statusCode = StatusCode.UNAVAILABLE; // if uncaught exception occurs repositoryServerAdapter = ClientUtil.getRepositoryServer(url.getHost(), url.getPort(), true); if (repositoryName == null) { - responseCode = GhidraURLConnection.GHIDRA_OK; - return responseCode; + statusCode = StatusCode.OK; + return statusCode; } repositoryAdapter = repositoryServerAdapter.getRepository(repositoryName); - repositoryAdapter.connect(); + if (repositoryServerAdapter.isConnected()) { + try { + repositoryAdapter.connect(); + } + catch (RepositoryNotFoundException e) { + statusCode = StatusCode.NOT_FOUND; + } + } + else if (!repositoryServerAdapter.isCancelled()) { + Throwable t = repositoryServerAdapter.getLastConnectError(); + if (t instanceof LoginException) { + statusCode = StatusCode.UNAUTHORIZED; + } + //throw new NotConnectedException("Not connected to repository server", t); + return statusCode; + } if (repositoryAdapter.isConnected()) { - responseCode = GhidraURLConnection.GHIDRA_OK; + statusCode = StatusCode.OK; if (!repositoryAdapter.getUser().hasWritePermission()) { if (!readOnly) { this.readOnly = true; // write access not permitted @@ -84,10 +101,10 @@ public class DefaultGhidraProtocolConnector extends GhidraProtocolConnector { resolveItemPath(); } else { - responseCode = GhidraURLConnection.GHIDRA_UNAUTHORIZED; + statusCode = StatusCode.UNAUTHORIZED; } - return responseCode; + return statusCode; } @Override diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/DefaultGhidraProtocolHandler.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/DefaultGhidraProtocolHandler.java index 069bc5e0b8..ff456a69a4 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/DefaultGhidraProtocolHandler.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/DefaultGhidraProtocolHandler.java @@ -18,11 +18,14 @@ package ghidra.framework.protocol.ghidra; import java.net.MalformedURLException; import java.net.URL; +import org.apache.commons.lang3.StringUtils; + /** * DefaultGhidraProtocolHandler provides the default protocol * handler which corresponds to the original RMI-based Ghidra Server * and local file-based Ghidra projects. - * {@literal ghidra://host/repo/... or ghidra:/path/projectName} + * {@literal ghidra://host/repo/... or ghidra:/path/projectName/...} + * See {@link DefaultGhidraProtocolConnector} and {@link DefaultLocalGhidraProtocolConnector} */ public class DefaultGhidraProtocolHandler extends GhidraProtocolHandler { @@ -35,7 +38,7 @@ public class DefaultGhidraProtocolHandler extends GhidraProtocolHandler { public GhidraProtocolConnector getConnector(URL ghidraUrl) throws MalformedURLException { String protocol = ghidraUrl.getProtocol(); if (protocol != null) { - if (ghidraUrl.getAuthority() == null) { + if (StringUtils.isBlank(ghidraUrl.getAuthority())) { return new DefaultLocalGhidraProtocolConnector(ghidraUrl); } return new DefaultGhidraProtocolConnector(ghidraUrl); diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/DefaultLocalGhidraProtocolConnector.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/DefaultLocalGhidraProtocolConnector.java index a9ef6e0e63..770a6117aa 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/DefaultLocalGhidraProtocolConnector.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/DefaultLocalGhidraProtocolConnector.java @@ -21,8 +21,12 @@ import java.net.URL; import ghidra.framework.client.NotConnectedException; import ghidra.framework.client.RepositoryAdapter; +import ghidra.framework.data.ProjectFileManager; import ghidra.framework.model.ProjectLocator; -import ghidra.framework.store.FileSystem; +import ghidra.framework.protocol.ghidra.GhidraURLConnection.StatusCode; +import ghidra.framework.store.LockException; +import ghidra.util.NotOwnerException; +import ghidra.util.ReadOnlyException; /** * DefaultLocalGhidraProtocolConnector provides support for the @@ -67,10 +71,12 @@ public class DefaultLocalGhidraProtocolConnector extends GhidraProtocolConnector @Override protected String parseItemPath() throws MalformedURLException { - // root folder access only - TODO: add support for specifying local item/folder path - folderPath = FileSystem.SEPARATOR; - folderItemName = null; - return folderPath; + + String path = url.getQuery(); + + initFolderItemPath(path); + + return path != null ? path : folderPath; } @Override @@ -94,22 +100,47 @@ public class DefaultLocalGhidraProtocolConnector extends GhidraProtocolConnector @Override public boolean isReadOnly() throws NotConnectedException { - if (responseCode == -1) { + if (statusCode == null) { throw new NotConnectedException("not connected"); } return readOnly; } @Override - public int connect(boolean readOnlyAccess) throws IOException { + public StatusCode connect(boolean readOnlyAccess) throws IOException { this.readOnly = readOnlyAccess; if (!localStorageLocator.exists()) { - responseCode = GhidraURLConnection.GHIDRA_NOT_FOUND; + statusCode = StatusCode.NOT_FOUND; } else { - responseCode = GhidraURLConnection.GHIDRA_OK; + statusCode = StatusCode.OK; } - return responseCode; + return statusCode; + } + + /** + * Connect and establish loca project project data instance. Opening a project for + * write access is subject to in-use lock restriction. + * See {@link #getStatusCode()} if null is returned. + * @param readOnlyAccess true if project data should be read-only + * @return project data instance or null if project not found + * @throws IOException if IO error occurs + */ + ProjectFileManager getLocalProjectData(boolean readOnlyAccess) throws IOException { + if (connect(readOnlyAccess) != StatusCode.OK) { + return null; + } + + try { + return new ProjectFileManager(localStorageLocator, !readOnlyAccess, false); + } + catch (NotOwnerException | ReadOnlyException e) { + statusCode = StatusCode.UNAUTHORIZED; + } + catch (LockException e) { + statusCode = StatusCode.LOCKED; + } + return null; } } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/GetUrlContentTypeTask.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/GetUrlContentTypeTask.java new file mode 100644 index 0000000000..0e2e519d75 --- /dev/null +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/GetUrlContentTypeTask.java @@ -0,0 +1,113 @@ +/* ### + * 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.protocol.ghidra; + +import java.io.*; +import java.net.MalformedURLException; +import java.net.URL; + +import ghidra.framework.model.DomainFile; +import ghidra.framework.protocol.ghidra.GhidraURLConnection.StatusCode; +import ghidra.util.Msg; +import ghidra.util.task.Task; +import ghidra.util.task.TaskMonitor; + +/** + * A blocking/modal Ghidra URL content type discovery task + */ +public class GetUrlContentTypeTask extends Task { + + private final URL ghidraUrl; + + private String contentType; + private boolean done = false; + + /** + * Construct a Ghidra URL content type discovery task + * @param ghidraUrl Ghidra URL (local or remote) + * @throws IllegalArgumentException if specified URL is not a Ghidra URL + * (see {@link GhidraURL}). + */ + public GetUrlContentTypeTask(URL ghidraUrl) { + super("Checking URL Content Type", true, false, true); + if (!GhidraURL.isLocalProjectURL(ghidraUrl) && + !GhidraURL.isServerRepositoryURL(ghidraUrl)) { + throw new IllegalArgumentException("unsupported URL"); + } + this.ghidraUrl = ghidraUrl; + } + + /** + * Get the discovered content type (e.g., "Program") + * @return content type or null if error occured or unsupported URL content + * @throws IllegalStateException if task has not completed execution + */ + public String getContentType() { + if (!done) { + throw new IllegalStateException("task has not completed"); + } + return contentType; + } + + @Override + public void run(TaskMonitor monitor) { + final Thread t = Thread.currentThread(); + monitor.addCancelledListener(() -> { + t.interrupt(); + }); + GhidraURLWrappedContent wrappedContent = null; + Object content = null; + try { + GhidraURLConnection c = (GhidraURLConnection) ghidraUrl.openConnection(); + Object obj = c.getContent(); // read-only access + if (c.getStatusCode() == StatusCode.UNAUTHORIZED) { + return; // assume user already notified + } + if (obj instanceof GhidraURLWrappedContent) { + wrappedContent = (GhidraURLWrappedContent) obj; + content = wrappedContent.getContent(this); + } + if (!(content instanceof DomainFile)) { + Msg.showError(this, null, "Unsupported Content", + "Invalid project file URL: " + ghidraUrl); + return; + } + contentType = ((DomainFile) content).getContentType(); + } + catch (FileNotFoundException e) { + Msg.showError(this, null, "Content Not Found", e.getMessage()); + } + catch (MalformedURLException e) { + Msg.showError(this, null, "Invalid Ghidra URL", + "Improperly formed Ghidra URL: " + ghidraUrl); + } + catch (InterruptedIOException e) { + // ignore - assume cancelled + } + catch (IOException e) { + Msg.showError(this, null, "URL Access Failure", + "Failed to open Ghidra URL: " + e.getMessage()); + } + finally { + if (content != null) { + wrappedContent.release(content, this); + } + done = true; + } + } + + +} diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/GhidraProtocolConnector.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/GhidraProtocolConnector.java index 8251bebe3f..f41706b2ca 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/GhidraProtocolConnector.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/GhidraProtocolConnector.java @@ -22,6 +22,7 @@ import java.net.URL; import org.apache.commons.lang3.StringUtils; import ghidra.framework.client.*; +import ghidra.framework.protocol.ghidra.GhidraURLConnection.StatusCode; import ghidra.framework.store.FileSystem; /** @@ -40,7 +41,7 @@ public abstract class GhidraProtocolConnector { protected String folderPath; protected String folderItemName = null; - protected int responseCode = -1; + protected StatusCode statusCode = null; protected RepositoryAdapter repositoryAdapter; protected RepositoryServerAdapter repositoryServerAdapter; @@ -109,7 +110,7 @@ public abstract class GhidraProtocolConnector { String path = url.getPath(); // Divide path into pieces - if (path == null || path.length() < 2 || path.charAt(0) != '/') { + if (StringUtils.isBlank(path) || path.length() < 2 || path.charAt(0) != '/') { return null; // content corresponds to RepositoryServerAdapter } @@ -128,6 +129,51 @@ public abstract class GhidraProtocolConnector { return path; } + /** + * Initialize {@code folderPath} and {@code folderItemName} from specified {@code contentPath}. + * @param contentPath absolute content path (null not permitted) + * @throws MalformedURLException if non-null invalid {@code contentPath} specified + * @return full content path + */ + protected final String initFolderItemPath(String contentPath) throws MalformedURLException { + + if (StringUtils.isBlank(contentPath)) { + folderPath = FileSystem.SEPARATOR; + return folderPath; + } + + if (!contentPath.startsWith(FileSystem.SEPARATOR)) { + throw new MalformedURLException("invalid content path specification"); + } + + boolean isFolder = contentPath.endsWith(FileSystem.SEPARATOR); + folderPath = ""; + String pathToSplit = + isFolder ? contentPath.substring(0, contentPath.length() - 1) : contentPath; + String[] pieces = + StringUtils.splitByWholeSeparatorPreserveAllTokens(pathToSplit, FileSystem.SEPARATOR); + if (pieces.length == 0) { + folderPath = FileSystem.SEPARATOR; + return folderPath; + } + for (int i = 1; i < pieces.length; i++) { + String p = pieces[i]; + if (p.length() == 0) { + throw new MalformedURLException("invalid content path specification"); + } + if (!isFolder && i == (pieces.length - 1)) { + folderItemName = p; + } + else { + folderPath = folderPath + FileSystem.SEPARATOR + p; + } + } + if (folderPath.length() == 0) { + folderPath = FileSystem.SEPARATOR; + } + return contentPath; + } + /** * Parse item path name from URL and establish initial values for folderPath and * folderItemName. @@ -144,48 +190,19 @@ public abstract class GhidraProtocolConnector { // strip off repository name from path path = path.substring(repositoryName.length() + 1); - if (path.length() <= 1) { - // root path specified - folderPath = FileSystem.SEPARATOR; - return folderPath; // repository URL, root folder - } - // Handles server repository URL case ghidra://:/[/]/[] - - boolean isFolder = path.endsWith(FileSystem.SEPARATOR); - folderPath = ""; - String pathToSplit = isFolder ? path.substring(0, path.length() - 1) : path; - String[] pieces = - StringUtils.splitByWholeSeparatorPreserveAllTokens(pathToSplit, FileSystem.SEPARATOR); - if (pieces.length == 0) { - throw new MalformedURLException("invalid repository path specification"); - } - for (int i = 1; i < pieces.length; i++) { - String p = pieces[i]; - if (p.length() == 0) { - throw new MalformedURLException("invalid repository path specification"); - } - if (!isFolder && i == (pieces.length - 1)) { - folderItemName = p; - } - else { - folderPath = folderPath + FileSystem.SEPARATOR + p; - } - } - if (folderPath.length() == 0) { - folderPath = FileSystem.SEPARATOR; - } + path = initFolderItemPath(path); return path; } /** - * Gets the status code from a Ghidra URL connect response. - * @return the Ghidra Status-Code, or -1 if not yet connected + * Gets the status code from a Ghidra URL connect attempt. + * @return the Ghidra status code or null if not yet connected * @see #connect(boolean) */ - public int getResponseCode() { - return responseCode; + public StatusCode getStatusCode() { + return statusCode; } /** @@ -247,12 +264,12 @@ public abstract class GhidraProtocolConnector { /** * Fully resolve folder/item reference once connected to the associated * repository due to possible ambiguity - * @throws IOException + * @throws IOException if an IO error occurs */ protected void resolveItemPath() throws IOException { // NOTE: Assume path may correspond to non-existent folder if not found - // - this is why GHIDRA_NOT_FOUND response code setting has been disabled + // - this is why NOT_FOUND status code setting has been disabled if (folderItemName != null) { if (itemPath.endsWith("/")) { @@ -262,7 +279,7 @@ public abstract class GhidraProtocolConnector { // if (readOnly && !repository.folderExists(itemPath)) { // // TODO: URL location not found -// responseCode = GHIDRA_NOT_FOUND; +// statusCode = NOT_FOUND; // return; // } } @@ -278,7 +295,7 @@ public abstract class GhidraProtocolConnector { // if (folderItemName != null) { // // TODO: URL location not found -// responseCode = GHIDRA_NOT_FOUND; +// statusCode = NOT_FOUND; // return; // } } @@ -289,13 +306,13 @@ public abstract class GhidraProtocolConnector { * Utilized a cached connection via the specified repository adapter. * This method may only be invoked if not yet connected and the associated * URL corresponds to a repository (getRepositoryName() != null). The connection - * response code should be established based upon the availability of the + * status code should be established based upon the availability of the * URL referenced repository resource (i.e., folder or file). * @param repository existing connected repository adapter - * @throws IOException + * @throws IOException if an IO error occurs */ protected void connect(RepositoryAdapter repository) throws IOException { - if (responseCode != -1) { + if (statusCode != null) { throw new IllegalStateException("already connected"); } if (repositoryName == null || !repositoryName.equals(repository.getName())) { @@ -304,7 +321,7 @@ public abstract class GhidraProtocolConnector { if (!repository.isConnected()) { throw new IllegalStateException("expected connected repository"); } - responseCode = GhidraURLConnection.GHIDRA_OK; + statusCode = StatusCode.OK; this.repositoryAdapter = repository; this.repositoryServerAdapter = repository.getServer(); resolveItemPath(); @@ -314,10 +331,10 @@ public abstract class GhidraProtocolConnector { * Connect to the resource specified by the associated URL. This method should only be invoked * once, a second attempt may result in an IOException. * @param readOnly if resource should be requested for write access. - * @return connection response code @see {@link GhidraURLConnection} + * @return connection status code * @throws IOException if a connection error occurs */ - public abstract int connect(boolean readOnly) throws IOException; + public abstract StatusCode connect(boolean readOnly) throws IOException; /** * Determines the read-only nature of a connected resource diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/GhidraURL.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/GhidraURL.java index cb7ab2d37a..b6ac57f582 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/GhidraURL.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/GhidraURL.java @@ -15,20 +15,30 @@ */ package ghidra.framework.protocol.ghidra; -import java.io.File; import java.net.*; +import java.util.Objects; import java.util.regex.Pattern; -import ghidra.framework.OperatingSystem; -import ghidra.framework.Platform; +import org.apache.commons.lang3.StringUtils; + import ghidra.framework.model.ProjectLocator; import ghidra.framework.remote.GhidraServerHandle; +/** + * Supported URL forms include: + *

      + *
    • {@literal ghidra://:/[/]/[[#ref]]}
    • + *
    • {@literal ghidra:/[X:/]/[?[/]/[[#ref]]]}
    • + *
    + */ public class GhidraURL { public static final String PROTOCOL = "ghidra"; - private static Pattern IS_LOCAL_URL_PATTERN = Pattern.compile("^" + PROTOCOL + ":/[^/].*"); // e.g., ghidra:/path + private static final String PROTOCOL_URL_START = PROTOCOL + ":/"; + + private static Pattern IS_LOCAL_URL_PATTERN = + Pattern.compile("^" + PROTOCOL_URL_START + "[^/].*"); // e.g., ghidra:/path public static final String MARKER_FILE_EXTENSION = ".gpr"; public static final String PROJECT_DIRECTORY_EXTENSION = ".rep"; @@ -39,29 +49,29 @@ public class GhidraURL { /** * Determine if the specified URL refers to a local project and * it exists. - * @param url + * @param url ghidra URL * @return true if specified URL refers to a local project and * it exists. */ public static boolean localProjectExists(URL url) { - if (!isLocalProjectURL(url)) { - return false; - } - String path = url.getPath(); // assume path always starts with '/' - if (Platform.CURRENT_PLATFORM.getOperatingSystem() == OperatingSystem.WINDOWS) { - if (path.indexOf(":/") == 2) { - path = path.substring(1); - } - } - File markerFile = new File(path + MARKER_FILE_EXTENSION); - File projectDir = new File(path + PROJECT_DIRECTORY_EXTENSION); - return (markerFile.isFile() && projectDir.isDirectory()); + ProjectLocator loc = getProjectStorageLocator(url); + return loc != null && loc.exists(); + } + + /** + * Determine if the specified string appears to be a possible ghidra URL + * (starts with "ghidra:/"). + * @param str string to be checked + * @return true if string is possible ghidra URL + */ + public static boolean isGhidraURL(String str) { + return str != null && str.startsWith(PROTOCOL_URL_START); } /** * Determine if the specified URL is a local project URL. * No checking is performed as to the existence of the project. - * @param url + * @param url ghidra URL * @return true if specified URL refers to a local * project (ghidra:/path/projectName...) */ @@ -70,83 +80,106 @@ public class GhidraURL { } /** - * Get the project name which corresponds to the specified - * local project URL. + * Get the project locator which corresponds to the specified local project URL. + * Confirm local project URL with {@link #isLocalProjectURL(URL)} prior to method use. * @param localProjectURL local Ghidra project URL - * @return project name - * @throws IllegalArgumentException URL is not a valid local project URL - */ - public static String getProjectName(URL localProjectURL) { - if (!isLocalProjectURL(localProjectURL)) { - throw new IllegalArgumentException("Invalid local Ghidra project URL"); - } - String path = localProjectURL.getPath(); - int index = path.lastIndexOf('/'); - return path.substring(index + 1); - } - - /** - * Get the project location path which corresponds to the specified - * local project URL. - * @param localProjectURL local Ghidra project URL - * @return project location path - * @throws IllegalArgumentException URL is not a valid local project URL - */ - public static String getProjectLocation(URL localProjectURL) { - if (!isLocalProjectURL(localProjectURL)) { - throw new IllegalArgumentException("Invalid local Ghidra project URL"); - } - String path = localProjectURL.getPath(); - int index = path.lastIndexOf('/'); - path = path.substring(0, index); - if (path.indexOf(":/") == 2) { - path = path.substring(1); - path = path.replace('/', File.separatorChar); - } - return path; - } - - /** - * Get the project locator which corresponds to the specified - * local project URL. - * @param localProjectURL local Ghidra project URL - * @return project locator + * @return project locator or null if invalid path specified * @throws IllegalArgumentException URL is not a valid local project URL */ public static ProjectLocator getProjectStorageLocator(URL localProjectURL) { if (!isLocalProjectURL(localProjectURL)) { throw new IllegalArgumentException("Invalid local Ghidra project URL"); } - String path = localProjectURL.getPath(); + + String path = localProjectURL.getPath(); // assume path always starts with '/' + +// if (path.indexOf(":/") == 2 && Character.isLetter(path.charAt(1))) { // check for drive letter after leading '/' +// if (Platform.CURRENT_PLATFORM.getOperatingSystem() == OperatingSystem.WINDOWS) { +// path = path.substring(1); // Strip-off leading '/' +// } +// else { +// // assume drive letter separator ':' should be removed for non-windows +// path = path.substring(0, 2) + path.substring(3); +// } +// } + int index = path.lastIndexOf('/'); - String dirPath = path.substring(0, index); - if (dirPath.endsWith(":")) { - dirPath += "/"; - } - if (dirPath.indexOf(":/") == 2) { - dirPath = dirPath.substring(1); - dirPath = dirPath.replace('/', File.separatorChar); - } + String dirPath = index != 0 ? path.substring(0, index) : "/"; + String name = path.substring(index + 1); + if (name.length() == 0) { + return null; + } + return new ProjectLocator(dirPath, name); } + /** + * Get the shared repository name associated with a repository URL or null + * if not applicable. For ghidra URL extensions it is assumed that the first path element + * corresponds to the repository name. + * @param url ghidra URL for shared project resource + * @return repository name or null if not applicable to URL + */ + public static String getRepositoryName(URL url) { + if (!isServerRepositoryURL(url)) { + return null; + } + String path = url.getPath(); + if (!path.startsWith("/")) { + // handle possible ghidra protocol extension use which is assumed to encode + // repository and file path the same as standard ghidra URL. + try { + URL extensionURL = new URL(path); + path = extensionURL.getPath(); + } + catch (MalformedURLException e) { + path = ""; + } + } + path = path.substring(1); + int ix = path.indexOf("/"); + if (ix > 0) { + path = path.substring(0, ix); + } + return path; + } + /** * Determine if the specified URL is any type of server "repository" URL. * No checking is performed as to the existence of the server or repository. - * @param url + * NOTE: ghidra protocol extensions are not currently supported (e.g., ghidra:http://...). + * @param url ghidra URL * @return true if specified URL refers to a Ghidra server * repository (ghidra://host/repositoryNAME/path...) */ public static boolean isServerRepositoryURL(URL url) { + if (!isServerURL(url)) { + return false; + } String path = url.getPath(); - return isServerURL(url) && path != null && path.length() > 0; + if (StringUtils.isBlank(path)) { + return false; + } + if (!path.startsWith("/")) { + try { + URL extensionURL = new URL(path); + path = extensionURL.getPath(); + if (StringUtils.isBlank(path)) { + return false; + } + } + catch (MalformedURLException e) { + return false; + } + } + return path.charAt(0) == '/' && path.length() > 1 && path.charAt(1) != '/'; } /** * Determine if the specified URL is any type of server URL. * No checking is performed as to the existence of the server or repository. - * @param url + * @param url ghidra URL * @return true if specified URL refers to a Ghidra server * repository (ghidra://host/repositoryNAME/path...) */ @@ -157,14 +190,21 @@ public class GhidraURL { return Handler.isSupportedURL(url); } + /** + * Ensure that absolute path is specified. Any use of Windows + * separator (back-slash) will be converted to a forward-slash. + * @param path path to be checked and possibly modified. + * @return path to be used + */ private static String checkAbsolutePath(String path) { path = path.replace('\\', '/'); if (!path.startsWith("/")) { - if (path.length() >= 3 && path.substring(1).startsWith(":/")) { + if (path.length() >= 3 && path.indexOf(":/") == 1 && + Character.isLetter(path.charAt(0))) { // prepend a "/" on Windows paths (e.g., C:/mydir) path = "/" + path; } - else { + else { // absence of drive letter is tolerated even if not absolute on windows throw new IllegalArgumentException("Absolute directory path required"); } } @@ -174,24 +214,28 @@ public class GhidraURL { /** * Create a Ghidra URL from a string form of Ghidra URL or local project path. * This method can consume strings produced by the getDisplayString method. - * @param projectPathOrURL {@literal project path (/)} + * @param projectPathOrURL {@literal project path (/)} or + * string form of Ghidra URL. * @return local Ghidra project URL * @see #getDisplayString(URL) * @throws IllegalArgumentException invalid path or URL specified */ public static URL toURL(String projectPathOrURL) { - String path = projectPathOrURL; - if (!path.startsWith(PROTOCOL + ":")) { - path = checkAbsolutePath(projectPathOrURL); - int index = path.lastIndexOf('/'); - if (index <= 0 || index == (path.length() - 1)) { - // Ensure that path includes projectName - throw new IllegalArgumentException("Invalid project path or URL"); + if (!projectPathOrURL.startsWith(PROTOCOL + ":")) { + if (projectPathOrURL.endsWith(ProjectLocator.PROJECT_DIR_SUFFIX) || + projectPathOrURL.endsWith(ProjectLocator.PROJECT_FILE_SUFFIX)) { + String ext = projectPathOrURL.substring(projectPathOrURL.lastIndexOf('.')); + throw new IllegalArgumentException("Project path must omit extension: " + ext); } - path = PROTOCOL + ":" + path; + if (projectPathOrURL.contains("?") || projectPathOrURL.contains("#")) { + throw new IllegalArgumentException("Unsupported query/ref used with project path"); + } + projectPathOrURL = checkAbsolutePath(projectPathOrURL); + String[] splitName = splitOffName(projectPathOrURL); + return makeURL(splitName[0], splitName[1]); } try { - return new URL(path); + return new URL(projectPathOrURL); } catch (MalformedURLException e) { throw new IllegalArgumentException(e); @@ -199,26 +243,151 @@ public class GhidraURL { } /** - * Get a normalized URL which eliminates use of host names and additional URL refs + * Get normalized URL which corresponds to the local-project or repository + * @param ghidraUrl ghidra file/folder URL (server-only URL not permitted) + * @return local-project or repository URL + */ + public static URL getProjectURL(URL ghidraUrl) { + if (!PROTOCOL.equals(ghidraUrl.getProtocol())) { + throw new IllegalArgumentException("ghidra protocol required"); + } + + if (isLocalProjectURL(ghidraUrl)) { + String urlStr = ghidraUrl.toExternalForm(); + int queryIx = urlStr.indexOf('?'); + if (queryIx < 0) { + return ghidraUrl; + } + urlStr = urlStr.substring(0, queryIx); + try { + return new URL(urlStr); + } + catch (MalformedURLException e) { + throw new RuntimeException(e); // unexpected + } + } + + if (isServerRepositoryURL(ghidraUrl)) { + + String path = ghidraUrl.getPath(); + // handle possible ghidra protocol extension use which is assumed to encode + // repository and file path the same as standard ghidra URL. + if (!path.startsWith("/")) { + try { + URL extensionURL = new URL(path); + path = extensionURL.getPath(); + } + catch (MalformedURLException e) { + path = "/"; + } + } + + // Truncate ghidra URL + String urlStr = ghidraUrl.toExternalForm(); + + String tail = null; + int ix = path.indexOf('/', 1); + if (ix > 0) { + // identify path tail to be removed + tail = path.substring(ix); + } + + int refIx = urlStr.indexOf('#'); + if (refIx > 0) { + urlStr = urlStr.substring(0, refIx); + } + int queryIx = urlStr.indexOf('?'); + if (queryIx > 0) { + urlStr = urlStr.substring(0, queryIx); + } + + if (tail != null) { + urlStr = urlStr.substring(0, urlStr.lastIndexOf(tail)); + } + try { + return new URL(urlStr); + } + catch (MalformedURLException e) { + // ignore + } + } + + throw new IllegalArgumentException("Invalid project/repository URL: " + ghidraUrl); + } + + /** + * Get the project pathname referenced by the specified Ghidra file/folder URL. + * If path is missing root folder is returned. + * @param ghidraUrl ghidra file/folder URL (server-only URL not permitted) + * @return pathname of file or folder + */ + public static String getProjectPathname(URL ghidraUrl) { + + if (isLocalProjectURL(ghidraUrl)) { + String query = ghidraUrl.getQuery(); + return StringUtils.isBlank(query) ? "/" : query; + } + + if (isServerRepositoryURL(ghidraUrl)) { + String path = ghidraUrl.getPath(); + // handle possible ghidra protocol extension use + if (!path.startsWith("/")) { + try { + URL extensionURL = new URL(path); + path = extensionURL.getPath(); + } + catch (MalformedURLException e) { + path = "/"; + } + } + // skip repo name (first path element) + int ix = path.indexOf('/', 1); + if (ix > 1) { + return path.substring(ix); + } + return "/"; + } + + throw new IllegalArgumentException("not project/repository URL"); + } + + /** + * Get hostname as an IP address if possible + * @param host hostname + * @return host IP address or original host name + */ + private static String getHostAsIpAddress(String host) { + if (!StringUtils.isBlank(host)) { + try { + InetAddress addr = InetAddress.getByName(host); + host = addr.getHostAddress(); + } + catch (UnknownHostException e) { + // just use hostname if unable to resolve + } + } + return host; + } + + /** + * Get a normalized URL which eliminates use of host names and optional URL ref * which may prevent direct comparison. - * @param url + * @param url ghidra URL * @return normalized url */ public static URL getNormalizedURL(URL url) { - if (!isServerRepositoryURL(url)) { - // TODO: May need to add support for other ghidra URL forms - return url; - } String host = url.getHost(); - try { - InetAddress addr = InetAddress.getByName(host); - host = addr.getHostAddress(); + String revisedHost = getHostAsIpAddress(host); + if (Objects.equals(host, revisedHost) && url.getRef() == null) { + return url; // no change } - catch (UnknownHostException e) { - // just use hostname if unable to resolve + String file = url.getPath(); + String query = url.getQuery(); + if (!StringUtils.isBlank(query)) { + file += "?" + query; } try { - return new URL(PROTOCOL, host, url.getPort(), url.getPath()); + return new URL(PROTOCOL, revisedHost, url.getPort(), file); } catch (MalformedURLException e) { throw new RuntimeException(e); @@ -228,14 +397,15 @@ public class GhidraURL { /** * Generate preferred display string for Ghidra URLs. * Form can be parsed by the toURL method. - * @param url - * @return + * @param url ghidra URL + * @return formatted URL display string * @see #toURL(String) */ public static String getDisplayString(URL url) { - if (isLocalProjectURL(url)) { + if (isLocalProjectURL(url) && StringUtils.isBlank(url.getQuery()) && + StringUtils.isBlank(url.getRef())) { String path = url.getPath(); - if (path.indexOf(":/") == 2) { + if (path.indexOf(":/") == 2 && Character.isLetter(path.charAt(1))) { // assume windows path path = path.substring(1); path = path.replace('/', '\\'); @@ -245,27 +415,6 @@ public class GhidraURL { return url.toString(); } - /** - * Create a local project URL for a specified project marker file. - * @param projectMarkerFile project marker file - * @return local project URL - */ - public static URL makeURL(File projectMarkerFile) { - String name = projectMarkerFile.getName(); - if (Platform.CURRENT_PLATFORM.getOperatingSystem() == OperatingSystem.WINDOWS) { - if (!name.endsWith(MARKER_FILE_EXTENSION) && - !name.toLowerCase().endsWith(MARKER_FILE_EXTENSION)) { - throw new IllegalArgumentException("Invalid project marker file"); - } - } - else if (!name.endsWith(MARKER_FILE_EXTENSION)) { - throw new IllegalArgumentException("Invalid project marker file"); - } - name = name.substring(0, name.length() - MARKER_FILE_EXTENSION.length()); - String location = projectMarkerFile.getParentFile().getAbsolutePath(); - return makeURL(location, name); - } - /** * Create a URL which refers to a local Ghidra project * @param dirPath absolute path of project location directory @@ -273,12 +422,54 @@ public class GhidraURL { * @return local Ghidra project URL */ public static URL makeURL(String dirPath, String projectName) { - String path = checkAbsolutePath(dirPath); + return makeURL(dirPath, projectName, null, null); + } + + /** + * Create a URL which refers to a local Ghidra project + * @param projectLocator absolute project location + * @return local Ghidra project URL + * @throws IllegalArgumentException if {@code projectLocator} does not have an absolute location + */ + public static URL makeURL(ProjectLocator projectLocator) { + return makeURL(projectLocator, null, null); + } + + /** + * Create a URL which refers to a local Ghidra project with optional project file and ref + * @param projectLocation absolute path of project location directory + * @param projectName name of project + * @param projectFilePath file path (e.g., /a/b/c, may be null) + * @param ref location reference (may be null) + * @return local Ghidra project URL + */ + public static URL makeURL(String projectLocation, String projectName, String projectFilePath, + String ref) { + if (StringUtils.isBlank(projectLocation) || StringUtils.isBlank(projectName)) { + throw new IllegalArgumentException("Inavlid project location and/or name"); + } + String path = checkAbsolutePath(projectLocation); if (!path.endsWith("/")) { path += "/"; } + StringBuilder buf = new StringBuilder(PROTOCOL); + buf.append(":"); + buf.append(path); + buf.append(projectName); + + if (!StringUtils.isBlank(projectFilePath)) { + if (!projectFilePath.startsWith("/") || projectFilePath.contains("\\")) { + throw new IllegalArgumentException("Invalid project file path"); + } + buf.append("?"); + buf.append(projectFilePath); + } + if (!StringUtils.isBlank(ref)) { + buf.append("#"); + buf.append(ref); + } try { - return new URL(PROTOCOL + ":" + path + projectName); + return new URL(buf.toString()); } catch (MalformedURLException e) { throw new IllegalArgumentException(e); @@ -286,17 +477,30 @@ public class GhidraURL { } /** - * Create a URL which refers to Ghidra Server repository content. Path may correspond - * to either a file or folder. - * @param host server host name/address - * @param port optional server port (a value <= 0 refers to the default port) - * @param repositoryName repository name - * @param repositoryPath absolute folder or file path within repository. - * Folder paths should end with a '/' character. - * @return Ghidra Server repository content URL + * Create a URL which refers to a local Ghidra project with optional project file and ref + * @param projectLocator local project locator + * @param projectFilePath file path (e.g., /a/b/c, may be null) + * @param ref location reference (may be null) + * @return local Ghidra project URL + * @throws IllegalArgumentException if invalid {@code projectFilePath} specified or if URL + * instantion fails. */ - public static URL makeURL(String host, int port, String repositoryName, String repositoryPath) { - return makeURL(host, port, repositoryName, repositoryPath, null, null); + public static URL makeURL(ProjectLocator projectLocator, String projectFilePath, String ref) { + return makeURL(projectLocator.getLocation(), projectLocator.getName(), projectFilePath, + ref); + } + + private static String[] splitOffName(String path) { + String name = ""; + if (!StringUtils.isBlank(path) && !path.endsWith("/")) { + int index = path.lastIndexOf('/'); + if (index >= 0) { + // last name may or may not be a folder name + name = path.substring(index + 1); + path = path.substring(0, index); + } + } + return new String[] { path, name }; } /** @@ -305,40 +509,76 @@ public class GhidraURL { * @param host server host name/address * @param port optional server port (a value <= 0 refers to the default port) * @param repositoryName repository name - * @param repositoryPath absolute folder path within repository. - * @param fileName name of a file contained within the specified repository/path - * @param ref optional URL ref or null + * @param repositoryPath absolute folder or file path within repository. + * Folder paths should end with a '/' character. + * @return Ghidra Server repository content URL + */ + public static URL makeURL(String host, int port, String repositoryName, String repositoryPath) { + String[] splitName = splitOffName(repositoryPath); + return makeURL(host, port, repositoryName, splitName[0], splitName[1], null); + } + + /** + * Create a URL which refers to Ghidra Server repository content. Path may correspond + * to either a file or folder. + * @param host server host name/address + * @param port optional server port (a value <= 0 refers to the default port) + * @param repositoryName repository name + * @param repositoryPath absolute folder or file path within repository. + * @param ref ref or null * Folder paths should end with a '/' character. * @return Ghidra Server repository content URL */ public static URL makeURL(String host, int port, String repositoryName, String repositoryPath, - String fileName, String ref) { - if (host == null) { + String ref) { + String[] splitName = splitOffName(repositoryPath); + return makeURL(host, port, repositoryName, splitName[0], splitName[1], ref); + } + + /** + * Create a URL which refers to Ghidra Server repository content. Path may correspond + * to either a file or folder. + * @param host server host name/address + * @param port optional server port (a value <= 0 refers to the default port) + * @param repositoryName repository name + * @param repositoryFolderPath absolute folder path within repository. + * @param fileName name of a file or folder contained within the specified {@code repositoryFolderPath} + * @param ref optional URL ref or null + * Folder paths should end with a '/' character. + * @return Ghidra Server repository content URL + * @throws IllegalArgumentException if invalid arguments are specified + */ + public static URL makeURL(String host, int port, String repositoryName, + String repositoryFolderPath, String fileName, String ref) { + if (StringUtils.isBlank(host)) { throw new IllegalArgumentException("host required"); } - if (repositoryName == null) { + if (StringUtils.isBlank(repositoryName)) { throw new IllegalArgumentException("repository name required"); } if (port == 0 || port == GhidraServerHandle.DEFAULT_PORT) { port = -1; } String path = "/" + repositoryName; - if (repositoryPath != null) { - if (!repositoryPath.startsWith("/") || repositoryPath.indexOf('\\') >= 0) { + if (!StringUtils.isBlank(repositoryFolderPath)) { + if (!repositoryFolderPath.startsWith("/") || repositoryFolderPath.indexOf('\\') >= 0) { throw new IllegalArgumentException("Invalid repository path"); } - path += repositoryPath; + path += repositoryFolderPath; + if (!path.endsWith("/")) { + path += "/"; + } } - else { - path += "/"; - } - if (fileName != null) { + if (!StringUtils.isBlank(fileName)) { + if (fileName.contains("/")) { + throw new IllegalArgumentException("Invalid folder/file name: " + fileName); + } if (!path.endsWith("/")) { path += "/"; } path += fileName; } - if (ref != null) { + if (!StringUtils.isBlank(ref)) { path += "#" + ref; } try { diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/GhidraURLConnection.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/GhidraURLConnection.java index e210fa05cb..e3e75b906c 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/GhidraURLConnection.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/GhidraURLConnection.java @@ -21,30 +21,85 @@ import java.net.*; import ghidra.framework.client.*; import ghidra.framework.data.ProjectFileManager; import ghidra.framework.model.ProjectData; -import ghidra.framework.model.ProjectLocator; -import ghidra.util.NotOwnerException; import ghidra.util.exception.AssertException; public class GhidraURLConnection extends URLConnection { + /** + * Connection status codes + */ + public enum StatusCode { + OK(20, "OK"), + /** + * Ghidra Status-Code 401: Unauthorized. + * This status code occurs when repository access is denied. + */ + UNAUTHORIZED(401, "Unauthorized"), + /** + * Ghidra Status-Code 404: Not Found. + * This status code occurs when repository or project does not exist. + */ + NOT_FOUND(404, "Not Found"), + /** + * Ghidra Status-Code 423: Locked. + * This status code occurs when project is locked (i.e., in use). + */ + LOCKED(423, "Locked Project"), + /** + * Ghidra Status-Code 503: Unavailable. + * This status code includes a variety of connection errors + * which are reported/logged by the Ghidra Server support code. + */ + UNAVAILABLE(503, "Unavailable"); + + private int code; + private String description; + + private StatusCode(int code, String description) { + this.code = code; + this.description = description; + } + + public int getCode() { + return code; + } + + public String getDescription() { + return description; + } + } + // TODO: consider implementing request and response headers - /** - * Ghidra Status-Code 200: OK. - */ - public static final int GHIDRA_OK = 200; - - /** - * Ghidra Status-Code 401: Unauthorized. - * This response code includes a variety of connection errors - * which are reported/logged by the Ghidra Server support code. - */ - public static final int GHIDRA_UNAUTHORIZED = 401; - - /** - * Ghidra Status-Code 404: Not Found. - */ - public static final int GHIDRA_NOT_FOUND = 404; +// /** +// * Ghidra Status-Code 200: OK. +// */ +// public static final int GHIDRA_OK = 200; +// +// /** +// * Ghidra Status-Code 401: Unauthorized. +// * This response code includes a variety of connection errors +// * which are reported/logged by the Ghidra Server support code. +// */ +// public static final int GHIDRA_UNAUTHORIZED = 401; +// +// /** +// * Ghidra Status-Code 404: Not Found. +// */ +// public static final int GHIDRA_NOT_FOUND = 404; +// +// /** +// * Ghidra Status-Code 423: Locked +// * Caused by attempt to open local project data with write-access when project is +// * already opened and locked. +// */ +// public static final int GHIDRA_LOCKED = 423; +// +// /** +// * Ghidra Status-Code 503: Unavailable +// * Caused by other connection failure +// */ +// public static final int GHIDRA_UNAVAILABLE = 503; /** * Ghidra content type - domain folder/file wrapped within GhidraURLWrappedContent object. @@ -58,7 +113,7 @@ public class GhidraURLConnection extends URLConnection { */ public static final String REPOSITORY_SERVER_CONTENT = "RepositoryServer"; - private int responseCode = -1; + private StatusCode statusCode = null; private GhidraProtocolConnector protocolConnector; @@ -110,16 +165,23 @@ public class GhidraURLConnection extends URLConnection { } /** - * Set the read-only state of the content. - * Extreme care must be taken when setting the state to false for local projects - * without the use of a ProjectLock. This setting is currently ignored - * for server repositories which are always read-only in Headed mode and - * read-write in Headless mode. + * Set the read-only state for this connection prior to connecting or getting content. + * The default access is read-only. Extreme care must be taken when setting the state to false + * for local projects without the use of a ProjectLock. + *

    + * NOTE: Local project URL connections only support read-only access. * @param state read-only if true, otherwise read-write + * @throws UnsupportedOperationException if an attempt is made to enable write access for + * a local project URL. + * @throws IllegalStateException if already connected */ public void setReadOnly(boolean state) { if (connected) throw new IllegalStateException("Already connected"); + if (GhidraURL.isLocalProjectURL(url) && !state) { + // local project write-access not supported due to inadequate cleanup/disposal strategy + throw new UnsupportedOperationException("write access to local projects not supported"); + } readOnly = state; } @@ -159,19 +221,19 @@ public class GhidraURLConnection extends URLConnection { } /** - * Gets the status code from a Ghidra URL response. + * Gets the status code from a Ghidra URL connect attempt. * @throws IOException if an error occurred connecting to the server. - * @return the Ghidra Status-Code, or -1 + * @return the Ghidra connection status code or null */ - public int getResponseCode() throws IOException { + public StatusCode getStatusCode() throws IOException { - if (responseCode != -1) { - return responseCode; + if (statusCode != null) { + return statusCode; } getContent(); // Ensure that we have connected to the server. - return responseCode; + return statusCode; } @Override @@ -195,7 +257,7 @@ public class GhidraURLConnection extends URLConnection { * @return URL content generally in the form of GhidraURLWrappedContent, although other * special cases may result in different content (Example: a server-only URL could result in * content class of RepositoryServerAdapter). - * @throws IOException + * @throws IOException if an IO error occurs */ @Override public Object getContent() throws IOException { @@ -214,7 +276,7 @@ public class GhidraURLConnection extends URLConnection { * failure to do so may prevent release of repository handle to server. * Only a single call to this method is permitted. * @return transient project data or null if unavailable - * @throws IOException + * @throws IOException if an IO error occurs */ public ProjectData getProjectData() throws IOException { @@ -228,24 +290,6 @@ public class GhidraURLConnection extends URLConnection { return projectData; } - private void localConnect(ProjectLocator localProjectLocator) throws IOException { - - responseCode = protocolConnector.connect(readOnly); - if (responseCode != GHIDRA_OK) { - return; - } - - try { - projectData = new ProjectFileManager(localProjectLocator, !readOnly, false); - - refObject = new GhidraURLWrappedContent(this); - responseCode = GHIDRA_OK; - } - catch (NotOwnerException e) { - responseCode = GHIDRA_UNAUTHORIZED; - } - } - @Override public void connect() throws IOException { @@ -258,9 +302,13 @@ public class GhidraURLConnection extends URLConnection { if (protocolConnector instanceof DefaultLocalGhidraProtocolConnector) { // local project connection DefaultLocalGhidraProtocolConnector localConnector = - ((DefaultLocalGhidraProtocolConnector) protocolConnector); - localConnect(localConnector.getLocalProjectLocator()); + (DefaultLocalGhidraProtocolConnector) protocolConnector; + projectData = localConnector.getLocalProjectData(readOnly); + statusCode = localConnector.getStatusCode(); connected = true; + if (statusCode == StatusCode.OK) { + refObject = new GhidraURLWrappedContent(this); + } return; } @@ -268,9 +316,9 @@ public class GhidraURLConnection extends URLConnection { if (repoName == null) { // assume only server adapter connection without repository name specified // any RepositoryServerAdapter caching is responsibility of connector - responseCode = protocolConnector.connect(readOnly); + statusCode = protocolConnector.connect(readOnly); connected = true; - if (responseCode == GHIDRA_OK) { + if (statusCode == StatusCode.OK) { refObject = protocolConnector.getRepositoryServerAdapter(); if (refObject == null) { throw new AssertException("expected RepositoryServerAdapter content"); @@ -292,9 +340,9 @@ public class GhidraURLConnection extends URLConnection { transientProjectManager.getTransientProject(protocolConnector, readOnly); connected = true; - responseCode = protocolConnector.getResponseCode(); + statusCode = protocolConnector.getStatusCode(); - if (responseCode != GHIDRA_OK) { + if (statusCode != StatusCode.OK) { return; } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/GhidraURLWrappedContent.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/GhidraURLWrappedContent.java index 9946bc4404..8e919c29a5 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/GhidraURLWrappedContent.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/GhidraURLWrappedContent.java @@ -1,6 +1,5 @@ /* ### * IP: GHIDRA - * REVIEWED: YES * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,14 +15,14 @@ */ package ghidra.framework.protocol.ghidra; -import ghidra.framework.model.*; -import ghidra.util.InvalidNameException; -import ghidra.util.exception.NotFoundException; - +import java.io.FileNotFoundException; import java.io.IOException; import java.util.ArrayList; import java.util.List; +import ghidra.framework.model.*; +import ghidra.util.InvalidNameException; + /** * GhidraURLWrappedContent provides controlled access to a Ghidra folder/file * associated with a Ghidra URL. It is important to note the issuance of this object does @@ -81,9 +80,11 @@ public class GhidraURLWrappedContent { throw new RuntimeException("consumer not found"); } + /** + * Close associated {@link ProjectData} when all consumers have released wrapped object. + */ private void closeProjectData() { - // Local project data can only be closed once and is not handled here - if (projectData instanceof TransientProjectData) { + if (projectData != null) { projectData.close(); } projectData = null; @@ -103,7 +104,7 @@ public class GhidraURLWrappedContent { return folder; } - private void resolve() throws IOException, NotFoundException { + private void resolve() throws IOException, FileNotFoundException { if (projectData != null) { return; @@ -125,13 +126,13 @@ public class GhidraURLWrappedContent { catch (InvalidNameException e) { // TODO: URL folder path is invalid closeProjectData(); - throw new NotFoundException("URL specifies invalid path: " + folderPath); + throw new IOException("URL specifies invalid path: " + folderPath); } } if (folder == null) { // TODO: URL location not found closeProjectData(); - throw new NotFoundException("URL specifies unknown path: " + folderPath); + throw new FileNotFoundException("URL specifies unknown path: " + folderPath); } if (folderItemName == null) { @@ -145,22 +146,29 @@ public class GhidraURLWrappedContent { return; } + DomainFolder subfolder = folder.getFolder(folderItemName); + if (subfolder != null) { + refObject = subfolder; + return; + } + closeProjectData(); - throw new NotFoundException("URL specifies unknown path: " + folderPath); + throw new FileNotFoundException("URL specifies unknown path: " + folderPath); } /** * Get the domain folder or file associated with the Ghidra URL. * The consumer is responsible for releasing the content object via the release method - * when it is no longer in use. + * when it is no longer in use (see {@link #release(Object, Object)}}). * @param consumer object which is responsible for releasing the content * @return domain file or folder - * @throws IOException - * @throws NotFoundException if the Ghidra URL does no correspond to a folder or file + * @throws IOException if an IO error occurs + * @throws FileNotFoundException if the Ghidra URL does no correspond to a folder or file * within the Ghidra repository/project. * @see #release(Object, Object) */ - public synchronized Object getContent(Object consumer) throws IOException, NotFoundException { + public synchronized Object getContent(Object consumer) + throws IOException, FileNotFoundException { addConsumer(consumer); boolean success = false; try { @@ -180,8 +188,8 @@ public class GhidraURLWrappedContent { * no longer in-use and the underlying connection may be closed. A read-only * or immutable domain object may remain open and in-use after its associated * domain folder/file has been released. - * @param content - * @param consumer + * @param content object obtained via {@link #getContent(Object)} + * @param consumer object consumer which was specified to {@link #getContent(Object)} */ public synchronized void release(Object content, Object consumer) { if (content == null || content != refObject) { diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/RepositoryInfo.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/RepositoryInfo.java index 5021420aa6..045ae46e55 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/RepositoryInfo.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/RepositoryInfo.java @@ -29,6 +29,14 @@ public class RepositoryInfo { this.readOnly = readOnly; } + /** + * Get the Ghidra URL which corresponds to the repository + * @return repository URL + */ + public URL getURL() { + return repositoryURL; + } + @Override public boolean equals(Object obj) { if (!(obj instanceof RepositoryInfo)) { diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/TransientProjectData.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/TransientProjectData.java index 389b7a4f15..411bf33734 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/TransientProjectData.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/TransientProjectData.java @@ -22,6 +22,7 @@ import ghidra.framework.client.RepositoryAdapter; import ghidra.framework.data.ProjectFileManager; import ghidra.framework.model.ProjectLocator; import ghidra.framework.remote.RepositoryHandle; +import ghidra.framework.store.LockException; import ghidra.util.Msg; import ghidra.util.SystemUtilities; import utilities.util.FileUtilities; @@ -38,7 +39,8 @@ public class TransientProjectData extends ProjectFileManager { private GhidraSwinglessTimer cleanupTimer; TransientProjectData(TransientProjectManager dataMgr, ProjectLocator tmpProjectLocation, - RepositoryInfo repositoryInfo, RepositoryAdapter repository) throws IOException { + RepositoryInfo repositoryInfo, RepositoryAdapter repository) + throws IOException, LockException { // Resulting data is read-only in GUI mode, read-write in Headless mode // Allowing more control will cause issues for caching of transient project data - // although we could use two caches one for each mode diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/TransientProjectManager.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/TransientProjectManager.java index f53ca14da5..fbd2a35c3c 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/TransientProjectManager.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/protocol/ghidra/TransientProjectManager.java @@ -25,6 +25,8 @@ import java.util.Set; import ghidra.framework.client.NotConnectedException; import ghidra.framework.client.RepositoryAdapter; import ghidra.framework.model.ProjectLocator; +import ghidra.framework.protocol.ghidra.GhidraURLConnection.StatusCode; +import ghidra.framework.store.LockException; import ghidra.util.Msg; import ghidra.util.SystemUtilities; import ghidra.util.datastruct.WeakValueHashMap; @@ -94,17 +96,11 @@ public class TransientProjectManager { * @param protocolConnector Ghidra protocol connector * @param readOnly true if project data should be treated as read-only * @return transient project data - * @throws IOException + * @throws IOException if an IO error occurs */ synchronized TransientProjectData getTransientProject(GhidraProtocolConnector protocolConnector, boolean readOnly) throws IOException { - TransientProjectData projectData; - - // try to avoid excessive accumulation of unreferenced transient project data. - // It is assumed that calls to this method are generally infrequent and may be slow - System.gc(); - String repoName = protocolConnector.getRepositoryName(); if (repoName == null) { throw new IllegalArgumentException( @@ -114,17 +110,18 @@ public class TransientProjectManager { RepositoryInfo repositoryInfo = new RepositoryInfo(protocolConnector.getRepositoryRootGhidraURL(), repoName, readOnly); - projectData = repositoryMap.get(repositoryInfo); + TransientProjectData projectData = repositoryMap.get(repositoryInfo); if (projectData == null || !projectData.stopCleanupTimer()) { // cleanup suspended - if (protocolConnector.connect(readOnly) != GhidraURLConnection.GHIDRA_OK) { - return null; + StatusCode statusCode = protocolConnector.connect(readOnly); + if (statusCode != StatusCode.OK) { + throw new NotConnectedException(statusCode.getDescription()); } RepositoryAdapter repositoryAdapter = protocolConnector.getRepositoryAdapter(); if (repositoryAdapter == null || !repositoryAdapter.isConnected()) { - throw new NotConnectedException("protocol connector not connected to repository"); + throw new NotConnectedException("Not connected to repository"); } projectData = createTransientProject(repositoryAdapter, repositoryInfo); @@ -182,7 +179,12 @@ public class TransientProjectManager { ProjectLocator tmpProjectLocation = new TransientProjectStorageLocator( tmp.getParentFile().getAbsolutePath(), tmp.getName(), repositoryInfo); - return new TransientProjectData(this, tmpProjectLocation, repositoryInfo, repository); + try { + return new TransientProjectData(this, tmpProjectLocation, repositoryInfo, repository); + } + catch (LockException e) { + throw new IOException(e); // unexpected for transient project storage + } } private static class TransientProjectStorageLocator extends ProjectLocator { diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/util/exception/BadLinkException.java b/Ghidra/Framework/Project/src/main/java/ghidra/util/exception/BadLinkException.java new file mode 100644 index 0000000000..6722c55c15 --- /dev/null +++ b/Ghidra/Framework/Project/src/main/java/ghidra/util/exception/BadLinkException.java @@ -0,0 +1,30 @@ +/* ### + * IP: GHIDRA + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ghidra.util.exception; + +import java.io.IOException; + +/** + * BadLinkException occurs when a link-file expected linked content type does not + * match the actual content type of the linked file. + */ +public class BadLinkException extends IOException { + + public BadLinkException(String msg) { + super(msg); + } + +} diff --git a/Ghidra/Framework/Project/src/main/resources/images/link.png b/Ghidra/Framework/Project/src/main/resources/images/link.png new file mode 100644 index 0000000000000000000000000000000000000000..024b295283129845b47147e2fa0f3f82f5a38fda GIT binary patch literal 2913 zcmV-n3!e0eP)|D^_ww@lRz|vCuzLs)$;-`! zo*{AqUjza0dRV*yaMRE;fKCVhpQKsoe1Yhg01=zBIT!& zC1$=TK@rP|Ibo3vKKm@PqnO#LJhq6%Ij6Hz*<$V$@wQAMN5qJ)hzm2hoGcOF60t^# zFqJFfH{#e-4l@G)6iI9sa9D{VHW4w29}?su;^hF~NC{tY+*d5%WDCTXa!E_i;d2ub z1#}&jF5T4HnnCyEWTkKf0>c0%E1Ah>(_PY1)0w;+02c53Su*0<(nUqKG_|(0G&D0Z z{i;y^b@OjZ+}lNZ8Th$p5Uu}MTtq^NHl z*T1?CO*}7&0ztZsv2j*bmJyf3G7=Z`5B*PvzoDiKdLpOAxi2$L0#SX*@cY_n(^h55xYX z#km%V()bZjV~l{*bt*u9?FT3d5g^g~#a;iSZ@&02Abxq_DwB(I|L-^bXThc7C4-yr zInE_0gw7K3GZ**7&k~>k0Z0NWkO#^@9q0fwx1%qjZ=)yBuQ3=5 z4Wo^*!gyjLF-e%Um=erBOdIALW)L%unZshS@>qSW9o8Sq#0s#5*edK%>{;v(b^`kb zN5rY%%y90wC>#%$kE_5P!JWYk;U;klcqzOl-UjcFXXA75rT9jCH~u<)0>40zCTJ7v z2qAyk54cquI@7b&LHdZ`+zlTss6bJ7%PQ)z$cROu4wBhpu-r)01)S~6}jY?%U? zgEALn#wiFzo#H}aQ8rT=DHkadR18&{>P1bW7E`~Y4p3)hWn`DhhRJ5j*2tcg9i<^O zEt(fCg;q*CP8+7ZTcWhYX$fb^_9d-LhL+6BEtPYWVlfK zTBusSTASKKb%HuWJzl+By+?gkLq)?+BTu761jmyXF)a;mc z^>(B7bo*HQ1NNg1st!zt28YLv>W*y3CdWx9U8f|cqfXDAO`Q48?auQqHZJR2&bcD4 z9Ip>EY~kKEPV6Wm+eXFV)D)_R=tM0@&p?(!V*Qu1PXHG9o^TY0bZ?)4%0 z1p8F`JoeS|<@=<@RE7GY07EYX@lwd>4oW|Yi!o+Su@M`;WuSK8LKk71XR(_ zRKHM1xJ5XYX`fk>`6eqY>qNG6HZQwBM=xi4&Sb88?zd}EYguc1@>KIS<&CX#T35dw zS|7K*XM_5Nf(;WJJvJWRMA($P>8E^?{IdL4o5MGE7bq2MEEwP7v8AO@qL5!WvekBL z-8R%V?zVyL=G&{be=K4bT`e{#t|)$A!YaA?jp;X)-+bB;zhj`(vULAW%ue3U;av{9 z4wp%n<(7@__S@Z2PA@Mif3+uO&y|X06?J#o zSi8M;ejj_^(0<4Lt#wLu#dYrva1Y$6_o(k^&}yhSh&h;f@JVA>W8b%oZ=0JGnu?n~ z9O4}sJsfnnx7n(>`H13?(iXTy*fM=I`sj`CT)*pTHEgYKqqP+u1IL8No_-(u{qS+0 z<2@%BCt82d{Gqm;(q7a7b>wu+b|!X?c13m#p7cK1({0<`{-e>4hfb-UsyQuty7Ua; zOu?B?XLHZaol8GAb3Wnxcu!2v{R_`T4=x`(GvqLI{-*2AOSimkUAw*F_TX^n z@STz9kDQ$NC=!KfXWC z8h`dn#xL(D3Z9UkR7|Q&Hcy#Notk!^zVUSB(}`#4&lYA1f0h2V_PNgUAAWQEt$#LR zcH#y9#i!p(Udq2b^lI6wp1FXzN3T;~FU%Lck$-deE#qz9yYP3D3t8{6?<+s(e(3(_ z^YOu_)K8!O1p}D#{JO;G(*OVf24YJ`L;(K){{a7>y{D4^000SaNLh0L01FcU01FcV z0GgZ_00007bV*G`2j&Y94>~u!NC^4>005~;L_t&-(`}Ef3c_F*g`del5WESy)t_t7 z3(!l@VD$=Yg26CwO+@8{hN!nfk7=Q-zayeLV_;2QH!lR*zZ zxW??~W2_WBUvFnN8+B%j9X^1Sf?OF!!>I#Kw+#R_fLs{>?vH~5l(rRML@*-sJ7c!z zH2_(z0DcqN3fV`wba-5$tyB%#h%=P2Xe$ozq9iSs;pOkmZo9=7>2OQ#_&7%500000 LNkvXXu0mjf<_CUM literal 0 HcmV?d00001 diff --git a/Ghidra/Framework/Project/src/test/java/ghidra/framework/model/TestDummyDomainFile.java b/Ghidra/Framework/Project/src/test/java/ghidra/framework/model/TestDummyDomainFile.java index 0ec40eb501..5e4ec72188 100644 --- a/Ghidra/Framework/Project/src/test/java/ghidra/framework/model/TestDummyDomainFile.java +++ b/Ghidra/Framework/Project/src/test/java/ghidra/framework/model/TestDummyDomainFile.java @@ -17,6 +17,7 @@ package ghidra.framework.model; import java.io.File; import java.io.IOException; +import java.net.URL; import java.util.ArrayList; import java.util.Map; @@ -100,11 +101,26 @@ public class TestDummyDomainFile implements DomainFile { throw new UnsupportedOperationException(); } + @Override + public URL getSharedProjectURL() { + throw new UnsupportedOperationException(); + } + @Override public String getContentType() { throw new UnsupportedOperationException(); } + @Override + public boolean isLinkFile() { + throw new UnsupportedOperationException(); + } + + @Override + public DomainFolder followLink() { + throw new UnsupportedOperationException(); + } + @Override public Class getDomainObjectClass() { throw new UnsupportedOperationException(); @@ -223,11 +239,6 @@ public class TestDummyDomainFile implements DomainFile { return isReadOnly; } - @Override - public boolean isVersionControlSupported() { - throw new UnsupportedOperationException(); - } - @Override public synchronized boolean isVersioned() { return isVersioned; @@ -325,6 +336,16 @@ public class TestDummyDomainFile implements DomainFile { throw new UnsupportedOperationException(); } + @Override + public boolean isLinkingSupported() { + throw new UnsupportedOperationException(); + } + + @Override + public DomainFile copyToAsLink(DomainFolder newParent) throws IOException { + throw new UnsupportedOperationException(); + } + @Override public DomainFile copyTo(DomainFolder newParent, TaskMonitor monitor) throws IOException, CancelledException { diff --git a/Ghidra/Framework/Project/src/test/java/ghidra/framework/model/TestDummyDomainFolder.java b/Ghidra/Framework/Project/src/test/java/ghidra/framework/model/TestDummyDomainFolder.java index 6f8fad1d82..df115ddac6 100644 --- a/Ghidra/Framework/Project/src/test/java/ghidra/framework/model/TestDummyDomainFolder.java +++ b/Ghidra/Framework/Project/src/test/java/ghidra/framework/model/TestDummyDomainFolder.java @@ -17,6 +17,7 @@ package ghidra.framework.model; import java.io.File; import java.io.IOException; +import java.net.URL; import java.util.ArrayList; import java.util.List; @@ -82,6 +83,11 @@ public class TestDummyDomainFolder implements DomainFolder { return "/"; } + @Override + public URL getSharedProjectURL() { + throw new UnsupportedOperationException(); + } + @Override public boolean isInWritableProject() { throw new UnsupportedOperationException(); @@ -162,6 +168,11 @@ public class TestDummyDomainFolder implements DomainFolder { throw new UnsupportedOperationException(); } + @Override + public DomainFile copyToAsLink(DomainFolder newParent) throws IOException { + throw new UnsupportedOperationException(); + } + @Override public void setActive() { throw new UnsupportedOperationException(); diff --git a/Ghidra/Framework/Project/src/test/java/ghidra/framework/model/TestDummyProjectData.java b/Ghidra/Framework/Project/src/test/java/ghidra/framework/model/TestDummyProjectData.java index 3a25bb8ccb..ca01d34d90 100644 --- a/Ghidra/Framework/Project/src/test/java/ghidra/framework/model/TestDummyProjectData.java +++ b/Ghidra/Framework/Project/src/test/java/ghidra/framework/model/TestDummyProjectData.java @@ -16,7 +16,6 @@ package ghidra.framework.model; import java.io.IOException; -import java.net.URL; import java.util.List; import ghidra.framework.client.RepositoryAdapter; @@ -84,12 +83,6 @@ public class TestDummyProjectData implements ProjectData { return null; } - @Override - public URL getSharedFileURL(String path) { - // stub - return null; - } - @Override public String makeValidName(String name) { // stub diff --git a/Ghidra/Framework/SoftwareModeling/src/main/java/ghidra/program/database/DataTypeArchiveContentHandler.java b/Ghidra/Framework/SoftwareModeling/src/main/java/ghidra/program/database/DataTypeArchiveContentHandler.java index 623a2e421e..f5d328b6c8 100644 --- a/Ghidra/Framework/SoftwareModeling/src/main/java/ghidra/program/database/DataTypeArchiveContentHandler.java +++ b/Ghidra/Framework/SoftwareModeling/src/main/java/ghidra/program/database/DataTypeArchiveContentHandler.java @@ -24,7 +24,8 @@ import db.DBHandle; import db.buffers.BufferFile; import db.buffers.ManagedBufferFile; import generic.theme.GIcon; -import ghidra.framework.data.*; +import ghidra.framework.data.DBContentHandler; +import ghidra.framework.data.DomainObjectMergeManager; import ghidra.framework.model.ChangeSet; import ghidra.framework.model.DomainObject; import ghidra.framework.store.*; @@ -39,13 +40,19 @@ import ghidra.util.task.TaskMonitor; * and FolderItem storage. This class also produces the appropriate Icon for * DataTypeArchive files. */ -public class DataTypeArchiveContentHandler extends DBContentHandler { +public class DataTypeArchiveContentHandler extends DBContentHandler { - private static Icon DATA_TYPE_ARCHIVE_ICON; + static Icon DATA_TYPE_ARCHIVE_ICON = new GIcon("icon.content.handler.archive.dt"); - private final static String PROGRAM_ICON_ID = "icon.content.handler.archive.dt"; public final static String DATA_TYPE_ARCHIVE_CONTENT_TYPE = "Archive"; + final static Class DATA_TYPE_ARCHIVE_DOMAIN_OBJECT_CLASS = + DataTypeArchiveDB.class; + final static String DATA_TYPE_ARCHIVE_CONTENT_DEFAULT_TOOL = "CodeBrowser"; + + private static final DataTypeArchiveLinkContentHandler linkHandler = + new DataTypeArchiveLinkContentHandler(); + @Override public long createFile(FileSystem fs, FileSystem userfs, String path, String name, DomainObject obj, TaskMonitor monitor) @@ -59,7 +66,7 @@ public class DataTypeArchiveContentHandler extends DBContentHandler { } @Override - public DomainObjectAdapter getImmutableObject(FolderItem item, Object consumer, int version, + public DataTypeArchiveDB getImmutableObject(FolderItem item, Object consumer, int version, int minChangeVersion, TaskMonitor monitor) throws IOException, VersionException, CancelledException { String contentType = item.getContentType(); @@ -113,7 +120,7 @@ public class DataTypeArchiveContentHandler extends DBContentHandler { } @Override - public DomainObjectAdapter getReadOnlyObject(FolderItem item, int version, boolean okToUpgrade, + public DataTypeArchiveDB getReadOnlyObject(FolderItem item, int version, boolean okToUpgrade, Object consumer, TaskMonitor monitor) throws IOException, VersionException, CancelledException { @@ -168,7 +175,7 @@ public class DataTypeArchiveContentHandler extends DBContentHandler { } @Override - public DomainObjectAdapter getDomainObject(FolderItem item, FileSystem userfs, long checkoutId, + public DataTypeArchiveDB getDomainObject(FolderItem item, FileSystem userfs, long checkoutId, boolean okToUpgrade, boolean recover, Object consumer, TaskMonitor monitor) throws IOException, VersionException, CancelledException { @@ -323,8 +330,8 @@ public class DataTypeArchiveContentHandler extends DBContentHandler { } @Override - public Class getDomainObjectClass() { - return DataTypeArchiveDB.class; + public Class getDomainObjectClass() { + return DATA_TYPE_ARCHIVE_DOMAIN_OBJECT_CLASS; } @Override @@ -339,16 +346,11 @@ public class DataTypeArchiveContentHandler extends DBContentHandler { @Override public String getDefaultToolName() { - return "CodeBrowser"; + return DATA_TYPE_ARCHIVE_CONTENT_DEFAULT_TOOL; } @Override public Icon getIcon() { - synchronized (DataTypeArchiveContentHandler.class) { - if (DATA_TYPE_ARCHIVE_ICON == null) { - DATA_TYPE_ARCHIVE_ICON = new GIcon(PROGRAM_ICON_ID); - } - } return DATA_TYPE_ARCHIVE_ICON; } @@ -364,4 +366,9 @@ public class DataTypeArchiveContentHandler extends DBContentHandler { originalObj, latestObj); } + @Override + public DataTypeArchiveLinkContentHandler getLinkHandler() { + return linkHandler; + } + } diff --git a/Ghidra/Framework/SoftwareModeling/src/main/java/ghidra/program/database/DataTypeArchiveLinkContentHandler.java b/Ghidra/Framework/SoftwareModeling/src/main/java/ghidra/program/database/DataTypeArchiveLinkContentHandler.java new file mode 100644 index 0000000000..7c64fdf1ac --- /dev/null +++ b/Ghidra/Framework/SoftwareModeling/src/main/java/ghidra/program/database/DataTypeArchiveLinkContentHandler.java @@ -0,0 +1,71 @@ +/* ### + * 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.program.database; + +import java.io.IOException; + +import javax.swing.Icon; + +import ghidra.framework.data.LinkHandler; +import ghidra.framework.data.URLLinkObject; +import ghidra.framework.model.DomainObject; +import ghidra.framework.store.FileSystem; +import ghidra.util.InvalidNameException; +import ghidra.util.exception.CancelledException; +import ghidra.util.task.TaskMonitor; + +public class DataTypeArchiveLinkContentHandler extends LinkHandler { + + public static final String ARCHIVE_LINK_CONTENT_TYPE = "ArchiveLink"; + + @Override + public long createFile(FileSystem fs, FileSystem userfs, String path, String name, + DomainObject obj, TaskMonitor monitor) + throws IOException, InvalidNameException, CancelledException { + if (!(obj instanceof URLLinkObject)) { + throw new IOException("Unsupported domain object: " + obj.getClass().getName()); + } + return createFile((URLLinkObject) obj, ARCHIVE_LINK_CONTENT_TYPE, fs, path, name, + monitor); + } + + @Override + public String getContentType() { + return ARCHIVE_LINK_CONTENT_TYPE; + } + + @Override + public String getContentTypeDisplayString() { + return "Data Type Archive Link"; + } + + @Override + public Class getDomainObjectClass() { + // return linked content class + return DataTypeArchiveContentHandler.DATA_TYPE_ARCHIVE_DOMAIN_OBJECT_CLASS; + } + + @Override + public Icon getIcon() { + return DataTypeArchiveContentHandler.DATA_TYPE_ARCHIVE_ICON; + } + + @Override + public String getDefaultToolName() { + return DataTypeArchiveContentHandler.DATA_TYPE_ARCHIVE_CONTENT_DEFAULT_TOOL; + } + +} diff --git a/Ghidra/Framework/SoftwareModeling/src/main/java/ghidra/program/database/ProgramContentHandler.java b/Ghidra/Framework/SoftwareModeling/src/main/java/ghidra/program/database/ProgramContentHandler.java index 7f124b27d9..a7d6c10c0b 100644 --- a/Ghidra/Framework/SoftwareModeling/src/main/java/ghidra/program/database/ProgramContentHandler.java +++ b/Ghidra/Framework/SoftwareModeling/src/main/java/ghidra/program/database/ProgramContentHandler.java @@ -23,7 +23,8 @@ import db.*; import db.buffers.BufferFile; import db.buffers.ManagedBufferFile; import generic.theme.GIcon; -import ghidra.framework.data.*; +import ghidra.framework.data.DBWithUserDataContentHandler; +import ghidra.framework.data.DomainObjectMergeManager; import ghidra.framework.model.ChangeSet; import ghidra.framework.model.DomainObject; import ghidra.framework.store.*; @@ -38,11 +39,16 @@ import ghidra.util.task.TaskMonitor; * and FolderItem storage. This class also produces the appropriate Icon for * Program files. */ -public class ProgramContentHandler extends DBContentHandler { +public class ProgramContentHandler extends DBWithUserDataContentHandler { + + public static final String PROGRAM_CONTENT_TYPE = "Program"; public static Icon PROGRAM_ICON = new GIcon("icon.content.handler.program"); - public static final String PROGRAM_CONTENT_TYPE = "Program"; + static final Class PROGRAM_DOMAIN_OBJECT_CLASS = ProgramDB.class; + static final String PROGRAM_CONTENT_DEFAULT_TOOL = "CodeBrowser"; + + private static final ProgramLinkContentHandler linkHandler = new ProgramLinkContentHandler(); @Override public long createFile(FileSystem fs, FileSystem userfs, String path, String name, @@ -52,11 +58,12 @@ public class ProgramContentHandler extends DBContentHandler { if (!(obj instanceof ProgramDB)) { throw new IOException("Unsupported domain object: " + obj.getClass().getName()); } - return createFile((ProgramDB) obj, PROGRAM_CONTENT_TYPE, fs, path, name, monitor); + return createFile((ProgramDB) obj, PROGRAM_CONTENT_TYPE, fs, path, name, + monitor); } @Override - public DomainObjectAdapter getImmutableObject(FolderItem item, Object consumer, int version, + public ProgramDB getImmutableObject(FolderItem item, Object consumer, int version, int minChangeVersion, TaskMonitor monitor) throws IOException, VersionException, CancelledException { String contentType = item.getContentType(); @@ -107,7 +114,7 @@ public class ProgramContentHandler extends DBContentHandler { } @Override - public DomainObjectAdapter getReadOnlyObject(FolderItem item, int version, boolean okToUpgrade, + public ProgramDB getReadOnlyObject(FolderItem item, int version, boolean okToUpgrade, Object consumer, TaskMonitor monitor) throws IOException, VersionException, CancelledException { @@ -161,7 +168,7 @@ public class ProgramContentHandler extends DBContentHandler { } @Override - public DomainObjectAdapter getDomainObject(FolderItem item, FileSystem userfs, long checkoutId, + public ProgramDB getDomainObject(FolderItem item, FileSystem userfs, long checkoutId, boolean okToUpgrade, boolean recover, Object consumer, TaskMonitor monitor) throws IOException, VersionException, CancelledException { @@ -316,8 +323,8 @@ public class ProgramContentHandler extends DBContentHandler { } @Override - public Class getDomainObjectClass() { - return ProgramDB.class; + public Class getDomainObjectClass() { + return PROGRAM_DOMAIN_OBJECT_CLASS; } @Override @@ -327,12 +334,12 @@ public class ProgramContentHandler extends DBContentHandler { @Override public String getContentTypeDisplayString() { - return "Program"; + return PROGRAM_CONTENT_TYPE; } @Override public String getDefaultToolName() { - return "CodeBrowser"; + return PROGRAM_CONTENT_DEFAULT_TOOL; } @Override @@ -352,4 +359,9 @@ public class ProgramContentHandler extends DBContentHandler { originalObj, latestObj); } + @Override + public ProgramLinkContentHandler getLinkHandler() { + return linkHandler; + } + } diff --git a/Ghidra/Framework/SoftwareModeling/src/main/java/ghidra/program/database/ProgramLinkContentHandler.java b/Ghidra/Framework/SoftwareModeling/src/main/java/ghidra/program/database/ProgramLinkContentHandler.java new file mode 100644 index 0000000000..09d02a94c1 --- /dev/null +++ b/Ghidra/Framework/SoftwareModeling/src/main/java/ghidra/program/database/ProgramLinkContentHandler.java @@ -0,0 +1,71 @@ +/* ### + * 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.program.database; + +import java.io.IOException; + +import javax.swing.Icon; + +import ghidra.framework.data.LinkHandler; +import ghidra.framework.data.URLLinkObject; +import ghidra.framework.model.DomainObject; +import ghidra.framework.store.FileSystem; +import ghidra.util.InvalidNameException; +import ghidra.util.exception.CancelledException; +import ghidra.util.task.TaskMonitor; + +public class ProgramLinkContentHandler extends LinkHandler { + + public static final String PROGRAM_LINK_CONTENT_TYPE = "ProgramLink"; + + @Override + public long createFile(FileSystem fs, FileSystem userfs, String path, String name, + DomainObject obj, TaskMonitor monitor) + throws IOException, InvalidNameException, CancelledException { + if (!(obj instanceof URLLinkObject)) { + throw new IOException("Unsupported domain object: " + obj.getClass().getName()); + } + return createFile((URLLinkObject) obj, PROGRAM_LINK_CONTENT_TYPE, fs, path, name, + monitor); + } + + @Override + public String getContentType() { + return PROGRAM_LINK_CONTENT_TYPE; + } + + @Override + public String getContentTypeDisplayString() { + return PROGRAM_LINK_CONTENT_TYPE; + } + + @Override + public Class getDomainObjectClass() { + // return linked content class + return ProgramContentHandler.PROGRAM_DOMAIN_OBJECT_CLASS; + } + + @Override + public Icon getIcon() { + return ProgramContentHandler.PROGRAM_ICON; + } + + @Override + public String getDefaultToolName() { + return ProgramContentHandler.PROGRAM_CONTENT_DEFAULT_TOOL; + } + +} diff --git a/Ghidra/Framework/SoftwareModeling/src/main/java/ghidra/program/database/ProgramUserDataDB.java b/Ghidra/Framework/SoftwareModeling/src/main/java/ghidra/program/database/ProgramUserDataDB.java index d866d23408..af1083569b 100644 --- a/Ghidra/Framework/SoftwareModeling/src/main/java/ghidra/program/database/ProgramUserDataDB.java +++ b/Ghidra/Framework/SoftwareModeling/src/main/java/ghidra/program/database/ProgramUserDataDB.java @@ -19,7 +19,6 @@ import java.io.IOException; import java.util.*; import db.*; -import ghidra.framework.data.ContentHandler; import ghidra.framework.data.DomainObjectAdapterDB; import ghidra.framework.store.FileSystem; import ghidra.program.database.map.AddressMapDB; @@ -44,6 +43,8 @@ class ProgramUserDataDB extends DomainObjectAdapterDB implements ProgramUserData // TODO: WARNING! This implementation does not properly handle undo/redo in terms of cache invalidation + private static ProgramContentHandler programContentHandler = new ProgramContentHandler(); + /** * DB_VERSION should be incremented any time a change is made to the overall * database schema associated with any of the managers. @@ -669,10 +670,7 @@ class ProgramUserDataDB extends DomainObjectAdapterDB implements ProgramUserData else { FileSystem userfs = program.getAssociatedUserFilesystem(); if (userfs != null) { - ContentHandler contentHandler = getContentHandler(program); - if (contentHandler != null) { - contentHandler.saveUserDataFile(program, dbh, userfs, monitor); - } + programContentHandler.saveUserDataFile(program, dbh, userfs, monitor); setChanged(false); } } diff --git a/Ghidra/Test/IntegrationTest/src/screen/java/help/screenshot/FrontEndPluginScreenShots.java b/Ghidra/Test/IntegrationTest/src/screen/java/help/screenshot/FrontEndPluginScreenShots.java index d26eb3a9b2..ed7dd59c1c 100644 --- a/Ghidra/Test/IntegrationTest/src/screen/java/help/screenshot/FrontEndPluginScreenShots.java +++ b/Ghidra/Test/IntegrationTest/src/screen/java/help/screenshot/FrontEndPluginScreenShots.java @@ -36,6 +36,7 @@ import docking.wizard.WizardPanel; import generic.theme.GThemeDefaults.Colors; import ghidra.app.plugin.core.archive.RestoreDialog; import ghidra.framework.data.GhidraFileData; +import ghidra.framework.data.ProjectFileManager; import ghidra.framework.main.*; import ghidra.framework.model.*; import ghidra.framework.plugintool.dialog.*; @@ -351,8 +352,13 @@ public class FrontEndPluginScreenShots extends GhidraScreenShotGenerator { @Test public void testProjectExists() { - OkDialog.show("Project Exists", - "Cannot restore project because project named TestPrj already exists."); + runSwing(() -> { + OkDialog.show("Project Exists", + "Cannot restore project because project named TestPrj already exists."); + }, false); + + waitForSwing(); + captureDialog(); } @@ -651,25 +657,60 @@ public class FrontEndPluginScreenShots extends GhidraScreenShotGenerator { @Test public void testViewOtherProjects() throws IOException, LockException, InvalidNameException, CancelledException { - ProjectTestUtils.deleteProject(TEMP_DIR, OTHER_PROJECT); + Project project = env.getProject(); program = env.getProgram("WinHelloCPP.exe"); ProjectData projectData = project.getProjectData(); projectData.getRootFolder().createFile("HelloCpp.exe", program, TaskMonitor.DUMMY); - project.close(); - + // Create other project to be viewed + ProjectTestUtils.deleteProject(TEMP_DIR, OTHER_PROJECT); Project otherProject = ProjectTestUtils.getProject(TEMP_DIR, OTHER_PROJECT); - Language language = getZ80_LANGUAGE(); ProjectTestUtils.createProgramFile(otherProject, "Program1", language, language.getDefaultCompilerSpec(), null); ProjectTestUtils.createProgramFile(otherProject, "Program2", language, language.getDefaultCompilerSpec(), null); + otherProject.close(); + + waitForSwing(); + + performAction("View Project", "FrontEndPlugin", false); + final GhidraFileChooser fileChooser = (GhidraFileChooser) getDialog(); + runSwing(() -> fileChooser.setSelectedFile(new File(TEMP_DIR, OTHER_PROJECT))); + pressButtonOnDialog("Select Project"); + setToolSize(500, 600); + captureToolWindow(700, 600); + + ProjectTestUtils.deleteProject(TEMP_DIR, OTHER_PROJECT); + ProjectTestUtils.deleteProject(TEMP_DIR, OTHER_PROJECT); + + } + + @Test + public void testLinkOtherProject() + throws IOException, LockException, InvalidNameException, CancelledException { + + Project project = env.getProject(); + program = env.getProgram("WinHelloCPP.exe"); + ProjectFileManager projectData = (ProjectFileManager) project.getProjectData(); + projectData.getRootFolder().createFile("HelloCpp.exe", program, TaskMonitor.DUMMY); + + // Create other project to be viewed + ProjectTestUtils.deleteProject(TEMP_DIR, OTHER_PROJECT); + Project otherProject = ProjectTestUtils.getProject(TEMP_DIR, OTHER_PROJECT); + Language language = getZ80_LANGUAGE(); + DomainFile otherFile = + ProjectTestUtils.createProgramFile(otherProject, "Program1", language, + language.getDefaultCompilerSpec(), null); + ProjectTestUtils.createProgramFile(otherProject, "Program2", language, + language.getDefaultCompilerSpec(), null); + + otherFile.copyToAsLink(projectData.getRootFolder()); otherProject.close(); - waitForSwing(); - project = ProjectTestUtils.getProject(TEMP_DIR, PROJECT_NAME); + + waitForBusyTool(tool); performAction("View Project", "FrontEndPlugin", false); final GhidraFileChooser fileChooser = (GhidraFileChooser) getDialog(); diff --git a/Ghidra/Test/IntegrationTest/src/screen/java/help/screenshot/VersionControlScreenShots.java b/Ghidra/Test/IntegrationTest/src/screen/java/help/screenshot/VersionControlScreenShots.java index 2ea3e10283..9588524b5e 100644 --- a/Ghidra/Test/IntegrationTest/src/screen/java/help/screenshot/VersionControlScreenShots.java +++ b/Ghidra/Test/IntegrationTest/src/screen/java/help/screenshot/VersionControlScreenShots.java @@ -55,7 +55,6 @@ public class VersionControlScreenShots extends GhidraScreenShotGenerator { VersionControlDialog dialog = new VersionControlDialog(false); dialog.setCurrentFileName(FrontEndTestEnv.PROGRAM_A); - dialog.setKeepCheckboxEnabled(true); runSwing(() -> tool.showDialog(dialog), false); VersionControlDialog d = waitForDialogComponent(VersionControlDialog.class); diff --git a/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/LaunchUrlInToolTest.java b/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/LaunchUrlInToolTest.java new file mode 100644 index 0000000000..9bd51ec1fa --- /dev/null +++ b/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/LaunchUrlInToolTest.java @@ -0,0 +1,343 @@ +/* ### + * 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 static org.junit.Assert.*; + +import java.io.File; +import java.net.URL; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.*; + +import docking.DialogComponentProvider; +import docking.test.AbstractDockingTest; +import ghidra.app.services.CodeViewerService; +import ghidra.app.services.ProgramManager; +import ghidra.framework.data.DomainFileProxy; +import ghidra.framework.model.*; +import ghidra.framework.plugintool.PluginTool; +import ghidra.framework.protocol.ghidra.GhidraURL; +import ghidra.framework.protocol.ghidra.Handler; +import ghidra.program.database.*; +import ghidra.program.model.address.Address; +import ghidra.program.model.address.AddressSpace; +import ghidra.program.model.listing.Program; +import ghidra.program.model.symbol.*; +import ghidra.program.util.ProgramLocation; +import ghidra.server.remote.ServerTestUtil; +import ghidra.test.*; +import ghidra.util.exception.AssertException; +import ghidra.util.task.TaskMonitor; +import utilities.util.FileUtilities; + +public class LaunchUrlInToolTest extends AbstractGhidraHeadedIntegrationTest { + + private TestEnv env; + private ProgramDB program; + + private File serverRoot; + + private static final String FILENAME = "Test"; + private static final String FOLDER = "/"; + private static final String FILEPATH = FOLDER + FILENAME; + private static final String NAMESPACE_NAME = "foo"; + private static final String SYMBOL_NAME = "xyz"; + private static final String REF = NAMESPACE_NAME + Namespace.DELIMITER + SYMBOL_NAME; + private static final String SYMBOL_ADDR = "0x1001030"; + private static final String REPO_NAME = "Test"; + + private URL remoteFileUrl; + + @Before + public void setUp() throws Exception { + + env = new TestEnv(); + + // NOTE: Use of tool templates requires active front-end tool + env.getFrontEndTool(); + + program = (ProgramDB) buildProgram(); + Project project = env.getProject(); + DomainFolder rootFolder = project.getProjectData().getRootFolder(); + rootFolder.createFile("Test", program, TaskMonitor.DUMMY); + } + + @After + public void tearDown() { + killServer(); + env.dispose(); + } + + private Program buildProgram() throws Exception { + ToyProgramBuilder builder = new ToyProgramBuilder(FILENAME, true, ProgramBuilder._TOY); + builder.createMemory("test1", "0x1001000", 0xb000); + builder.addBytesFallthrough("0x1001010"); + builder.addBytesFallthrough("0x1001020"); + builder.addBytesFallthrough("0x1001030"); + builder.addBytesFallthrough("0x1001040"); + builder.disassemble("0x1001010", 1); + builder.disassemble("0x1001020", 1); + builder.disassemble("0x1001030", 1); + builder.disassemble("0x1001040", 1); + Program p = builder.getProgram(); + + int txId = p.startTransaction("Add Label"); + try { + AddressSpace space = p.getAddressFactory().getDefaultAddressSpace(); + Address addr = space.getAddress(SYMBOL_ADDR); + SymbolTable symbolTable = p.getSymbolTable(); + Namespace ns = + symbolTable.createNameSpace(null, NAMESPACE_NAME, SourceType.USER_DEFINED); + symbolTable.createLabel(addr, SYMBOL_NAME, ns, SourceType.USER_DEFINED); + } + catch (Exception e) { + throw new AssertException(e); + } + finally { + p.endTransaction(txId, true); + } + + return p; + } + + @Test + public void testLocalLaunchDefaultTool() throws Exception { + + Project project = env.getProject(); + setupDefaultTestTool(project); + + ProjectLocator projectLocator = env.getProject().getProjectLocator(); + + URL url = GhidraURL.makeURL(projectLocator, FILEPATH, REF); + + AtomicReference ref = new AtomicReference<>(); + runSwing(() -> { + boolean wasErrorGUIEnabled = AbstractDockingTest.isUseErrorGUI(); + ToolServices toolServices = project.getToolServices(); + ref.set(toolServices.launchDefaultToolWithURL(url)); + AbstractDockingTest.setErrorGUIEnabled(wasErrorGUIEnabled); + }); + + verifyLaunch(ref.get()); + } + + @Test + public void testLocalLaunchNamedTool() throws Exception { + + Project project = env.getProject(); + ProjectLocator projectLocator = project.getProjectLocator(); + + URL url = GhidraURL.makeURL(projectLocator, FILEPATH, REF); + + AtomicReference ref = new AtomicReference<>(); + runSwing(() -> { + boolean wasErrorGUIEnabled = AbstractDockingTest.isUseErrorGUI(); + ToolServices toolServices = project.getToolServices(); + ref.set(toolServices.launchToolWithURL(DEFAULT_TEST_TOOL_NAME, url)); + AbstractDockingTest.setErrorGUIEnabled(wasErrorGUIEnabled); + }); + + verifyLaunch(ref.get()); + } + + @Test + public void testBad1LocalLaunchDefaultTool() throws Exception { + + Project project = env.getProject(); + setupDefaultTestTool(project); + + ProjectLocator projectLocator = env.getProject().getProjectLocator(); + + URL url = GhidraURL.makeURL(projectLocator, FOLDER, null); + + ToolServices toolServices = project.getToolServices(); + PluginTool tool = toolServices.launchDefaultToolWithURL(url); + assertNull(tool); + + DialogComponentProvider dlg = waitForDialogComponent("Unsupported Content"); + assertNotNull("Error dialog expected", dlg); + runSwing(() -> dlg.close()); + } + + @Test + public void testBad2LocalLaunchDefaultTool() throws Exception { + + Project project = env.getProject(); + setupDefaultTestTool(project); + + ProjectLocator projectLocator = env.getProject().getProjectLocator(); + + URL url = GhidraURL.makeURL(projectLocator, "/x/y", null); + + ToolServices toolServices = project.getToolServices(); + PluginTool tool = toolServices.launchDefaultToolWithURL(url); + assertNull(tool); + + DialogComponentProvider dlg = waitForDialogComponent("Content Not Found"); + assertNotNull("Error dialog expected", dlg); + runSwing(() -> dlg.close()); + } + + @Test + public void testRemoteLaunchDefaultTool() throws Exception { + startServer(); // also changes user's identity + + Project project = env.getProject(); + setupDefaultTestTool(project); + + AtomicReference ref = new AtomicReference<>(); + runSwing(() -> { + boolean wasErrorGUIEnabled = AbstractDockingTest.isUseErrorGUI(); + ToolServices toolServices = project.getToolServices(); + ref.set(toolServices.launchDefaultToolWithURL(remoteFileUrl)); + AbstractDockingTest.setErrorGUIEnabled(wasErrorGUIEnabled); + }); + + verifyLaunch(ref.get()); + } + + @Test + public void testRemoteLaunchNamedTool() throws Exception { + startServer(); // also changes user's identity + + Project project = env.getProject(); + setupDefaultTestTool(project); + + AtomicReference ref = new AtomicReference<>(); + runSwing(() -> { + boolean wasErrorGUIEnabled = AbstractDockingTest.isUseErrorGUI(); + ToolServices toolServices = project.getToolServices(); + ref.set(toolServices.launchToolWithURL(DEFAULT_TEST_TOOL_NAME, remoteFileUrl)); + AbstractDockingTest.setErrorGUIEnabled(wasErrorGUIEnabled); + }); + + verifyLaunch(ref.get()); + } + + @Test + public void testRemoteBad1LaunchDefaultTool() throws Exception { + startServer(); // also changes user's identity + + Project project = env.getProject(); + setupDefaultTestTool(project); + + URL badUrl = GhidraURL.makeURL(ServerTestUtil.LOCALHOST, + ServerTestUtil.GHIDRA_TEST_SERVER_PORT, REPO_NAME, FOLDER, null, null); + + ToolServices toolServices = project.getToolServices(); + PluginTool tool = toolServices.launchDefaultToolWithURL(badUrl); + assertNull(tool); + + DialogComponentProvider dlg = waitForDialogComponent("Unsupported Content"); + assertNotNull("Error dialog expected", dlg); + runSwing(() -> dlg.close()); + } + + @Test + public void testRemoteBad2LaunchDefaultTool() throws Exception { + startServer(); // also changes user's identity + + Project project = env.getProject(); + setupDefaultTestTool(project); + + URL badUrl = GhidraURL.makeURL(ServerTestUtil.LOCALHOST, + ServerTestUtil.GHIDRA_TEST_SERVER_PORT, REPO_NAME, FOLDER, "x", REF); + + ToolServices toolServices = project.getToolServices(); + PluginTool tool = toolServices.launchDefaultToolWithURL(badUrl); + assertNull(tool); + + DialogComponentProvider dlg = waitForDialogComponent("Content Not Found"); + assertNotNull("Error dialog expected", dlg); + runSwing(() -> dlg.close()); + } + + private void verifyLaunch(PluginTool tool) throws Exception { + assertNotNull("tool failed to launch", tool); + + ProgramManager pm = tool.getService(ProgramManager.class); + assertNotNull("ProgramManager not found", pm); + + CodeViewerService codeViewer = tool.getService(CodeViewerService.class); + assertNotNull("CodeViewerService not found", codeViewer); + + ProgramLocation currentLocation = codeViewer.getCurrentLocation(); + assertNotNull("Failed to determine current location", currentLocation); + + Program p = currentLocation.getProgram(); + + // Verify that it was not directly opened via active Project + assertTrue(p.getDomainFile() instanceof DomainFileProxy); + + AddressSpace space = p.getAddressFactory().getDefaultAddressSpace(); + Address addr = space.getAddress("0x1001030"); + assertEquals(addr, currentLocation.getAddress()); + } + + private void setupDefaultTestTool(Project project) { + ToolServices toolServices = project.getToolServices(); + ProgramContentHandler handler = new ProgramContentHandler(); + ToolTemplate toolTemplate = + project.getLocalToolChest().getToolTemplate(DEFAULT_TEST_TOOL_NAME); + assertNotNull(toolTemplate); + ToolAssociationInfo info = + new ToolAssociationInfo(handler, DEFAULT_TEST_TOOL_NAME, toolTemplate, toolTemplate); + toolServices.setContentTypeToolAssociations(Set.of(info)); + } + + private void killServer() { + + if (serverRoot == null) { + return; + } + + ServerTestUtil.disposeServer(); + + FileUtilities.deleteDir(serverRoot); + } + + private void startServer() throws Exception { + + // register ghidra protocol and define remote URL to access Test file + Handler.registerHandler(); + remoteFileUrl = GhidraURL.makeURL(ServerTestUtil.LOCALHOST, + ServerTestUtil.GHIDRA_TEST_SERVER_PORT, REPO_NAME, FOLDER, FILENAME, REF); + + // Create server instance + serverRoot = new File(getTestDirectoryPath(), "TestServer"); + FileUtilities.deleteDir(serverRoot); + + // Authorized admin user "test" is predefined by ServerTestUtil.createPopulatedTestServer + ServerTestUtil.setLocalUser(ServerTestUtil.ADMIN_USER); + + ServerTestUtil.createPopulatedTestServer(serverRoot.getAbsolutePath(), + REPO_NAME, fs -> { + try { + ServerTestUtil.createRepositoryItem(fs, FILENAME, FOLDER, program); + } + catch (Exception e) { + failWithException("Failed added server content", e); + } + }); + + ServerTestUtil.startServer(serverRoot.getAbsolutePath(), + ServerTestUtil.GHIDRA_TEST_SERVER_PORT, -1, false, false, false); + + } + +} diff --git a/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/server/remote/ServerTestUtil.java b/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/server/remote/ServerTestUtil.java index 9b2dcdd337..d006558cbc 100644 --- a/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/server/remote/ServerTestUtil.java +++ b/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/server/remote/ServerTestUtil.java @@ -21,6 +21,7 @@ import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry; import java.security.KeyStore.PrivateKeyEntry; import java.util.ArrayList; +import java.util.function.Consumer; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -114,7 +115,6 @@ public class ServerTestUtil { private static class ShutdownHook extends Thread { @Override public void run() { - Msg.debug(ServerTestUtil.class, "\n\n\n\n\tSHUTDOWN HOOK RUNNING"); disposeServer(); } } @@ -626,7 +626,6 @@ public class ServerTestUtil { System.setProperty(ApplicationTrustManagerFactory.GHIDRA_CACERTS_PATH_PROPERTY, ""); - Msg.debug(ServerTestUtil.class, "disposeServer() - process exist? " + serverProcess); if (serverProcess != null) { cmdOut.dispose(); @@ -752,7 +751,7 @@ public class ServerTestUtil { * dispose method on the returned object. * @throws IOException */ - public static LocalFileSystem createRepository(String dirPath, String repoName, + private static LocalFileSystem createRepository(String dirPath, String repoName, String... userAccessLines) throws IOException { File repoDir = new File(dirPath, NamingUtilities.mangle(repoName)); @@ -767,7 +766,7 @@ public class ServerTestUtil { return repoFileSystem; } - public static void createRepositoryItem(LocalFileSystem repoFilesystem, String name, + private static void createRepositoryItem(LocalFileSystem repoFilesystem, String name, String folderPath, int numFunctions) throws Exception { ToyProgramBuilder builder = new ToyProgramBuilder(name, true); @@ -787,14 +786,7 @@ public class ServerTestUtil { setProgramHashes(program); - ContentHandler contentHandler = DomainObjectAdapter.getContentHandler(program); - long checkoutId = contentHandler.createFile(repoFilesystem, null, folderPath, name, - program, TaskMonitor.DUMMY); - LocalFolderItem item = repoFilesystem.getItem(folderPath, name); - if (item == null) { - throw new IOException("Item not found: " + FileSystem.SEPARATOR + name); - } - item.terminateCheckout(checkoutId, false); + createRepositoryItem(repoFilesystem, name, folderPath, program); } catch (CancelledException e) { throw new RuntimeException(e); // unexpected @@ -807,6 +799,19 @@ public class ServerTestUtil { } } + public static void createRepositoryItem(LocalFileSystem repoFilesystem, String name, + String folderPath, Program program) throws Exception { + + ContentHandler contentHandler = DomainObjectAdapter.getContentHandler(program); + long checkoutId = contentHandler.createFile(repoFilesystem, null, folderPath, name, + program, TaskMonitor.DUMMY); + LocalFolderItem item = repoFilesystem.getItem(folderPath, name); + if (item == null) { + throw new IOException("Item not found: " + FileSystem.SEPARATOR + name); + } + item.terminateCheckout(checkoutId, false); + } + /** * Create and populate server test repositories "Test" and "Test1" with the specified * users added. The ADMIN_USER "test" is added by default. @@ -854,6 +859,43 @@ public class ServerTestUtil { } } + /** + * Create and populate server test repositories "Test" and "Test1" with the specified + * users added. The ADMIN_USER "test" is added by default. + * @param dirPath server root + * @param repoName repository name + * @param contentProvider repository content provider callback + * (use {@link #createRepositoryItem(LocalFileSystem, String, String, Program)} to add content. + * @param users optional inclusion of USER_A and/or USER_B to be added with no authentication required + * @throws Exception + */ + public static void createPopulatedTestServer(String dirPath, String repoName, + Consumer contentProvider, String... users) throws Exception { + + Msg.info(ServerTestUtil.class, "Constructing Ghidra Server for testing: " + dirPath); + + File rootDir = new File(dirPath); + FileUtilities.deleteDir(rootDir); + FileUtilities.mkdirs(rootDir); + + String[] userArray = new String[users.length + 1]; + userArray[0] = ADMIN_USER; + System.arraycopy(users, 0, userArray, 1, users.length); + createUsers(dirPath, userArray); + + String keys[] = SSHKeyUtil.generateSSHRSAKeys(); + addSSHKeys(dirPath, keys[0], "test.key", keys[1], "test.pub"); + + LocalFileSystem repoFilesystem = createRepository(dirPath, repoName, ADMIN_USER + "=ADMIN", + USER_A + "=READ_ONLY", USER_B + "=WRITE"); + try { + contentProvider.accept(repoFilesystem); + } + finally { + repoFilesystem.dispose(); + } + } + /** * Sets dummy hash values for the given program. * diff --git a/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/test/DataTreeHelper.java b/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/test/DataTreeHelper.java new file mode 100644 index 0000000000..bcd6d81151 --- /dev/null +++ b/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/test/DataTreeHelper.java @@ -0,0 +1,152 @@ +/* ### + * 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.test; + +import java.util.ArrayList; +import java.util.List; + +import javax.swing.tree.TreePath; + +import docking.ActionContext; +import docking.test.AbstractDockingTest; +import docking.widgets.tree.GTree; +import docking.widgets.tree.GTreeNode; +import generic.test.AbstractGTest; +import generic.test.AbstractGenericTest; +import ghidra.framework.main.datatable.ProjectDataContext; +import ghidra.framework.main.datatree.*; +import ghidra.framework.model.DomainFile; +import ghidra.framework.model.DomainFolder; +import ghidra.framework.store.FileSystem; + +/** + * This class provides some convenience methods for interacting with a {@link DataTree}. + */ +public class DataTreeHelper { + + private boolean isFrontEndTree; + private DataTree tree; + private DomainFolderRootNode rootNode; + + public DataTreeHelper(DataTree tree, boolean isFrontEndTree) { + this.tree = tree; + this.isFrontEndTree = isFrontEndTree; + rootNode = (DomainFolderRootNode) tree.getViewRoot(); + } + + public void waitForTree() { + AbstractDockingTest.waitForTree(tree); + } + + public DomainFolder getRootFolder() { + return rootNode.getDomainFolder(); + } + + public GTree getTree() { + return tree; + } + + public GTreeNode getRootNode() { + return tree.getModelRoot(); + } + + private GTreeNode getDataTreeNodeByPath(String path) { + int len = path.length(); + if (len == 0 || path.charAt(0) != FileSystem.SEPARATOR_CHAR) { + throw new IllegalArgumentException( + "Absolute path must begin with '" + FileSystem.SEPARATOR_CHAR + "'"); + } + + GTreeNode node = rootNode; + String[] split = path.split(FileSystem.SEPARATOR); + if (split.length == 0) { + return node; + } + + for (int i = 1; i < split.length; i++) { + GTreeNode child = getChild(node, split[i]); + if (child == null) { + return null; + } + node = child; + } + return node; + } + + private GTreeNode getChild(GTreeNode parent, String name) { + return AbstractGTest.waitForValue(() -> parent.getChild(name)); + } + + public GTreeNode waitForTreeNode(String name) { + return name.startsWith(FileSystem.SEPARATOR) ? getDataTreeNodeByPath(name) + : getChild(rootNode, name); + } + + public DomainFileNode waitForFileNode(String name) { + return (DomainFileNode) waitForTreeNode(name); + } + + public DomainFolderNode waitForFolderNode(String name) { + return (DomainFolderNode) waitForTreeNode(name); + } + + public void clearTreeSelection() { + AbstractGenericTest.runSwing(() -> tree.clearSelection()); + } + + public void setTreeSelection(final TreePath[] paths) throws Exception { + tree.setSelectionPaths(paths); + waitForTree(); + } + + public void selectNodes(GTreeNode... nodes) { + tree.setSelectedNodes(nodes); + waitForTree(); + } + + public void expandNode(GTreeNode node) { + tree.expandPath(node); + waitForTree(); + } + + public ActionContext getDomainFileActionContext(GTreeNode... nodes) { + + List fileList = new ArrayList<>(); + List folderList = new ArrayList<>(); + TreePath[] treePaths = new TreePath[nodes.length]; + for (int i = 0; i < nodes.length; i++) { + GTreeNode node = nodes[i]; + treePaths[i] = node.getTreePath(); + if (node instanceof DomainFileNode) { + fileList.add(((DomainFileNode) node).getDomainFile()); + } + else if (node instanceof DomainFolderNode) { + folderList.add(((DomainFolderNode) node).getDomainFolder()); + } + } + + if (isFrontEndTree) { + boolean isActiveProject = tree.getName().equals("Data Tree"); + return new FrontEndProjectTreeContext(null, rootNode.getDomainFolder().getProjectData(), + treePaths, folderList, fileList, tree, isActiveProject); + } + + return new ProjectDataContext(null, rootNode.getDomainFolder().getProjectData(), nodes[0], + folderList, fileList, tree, true); + + } + +} diff --git a/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/test/FrontEndTestEnv.java b/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/test/FrontEndTestEnv.java index f7141a5476..0ffc529e39 100644 --- a/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/test/FrontEndTestEnv.java +++ b/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/test/FrontEndTestEnv.java @@ -33,7 +33,6 @@ import generic.test.AbstractGTest; import generic.test.AbstractGenericTest; import ghidra.framework.main.FrontEndTool; import ghidra.framework.main.SharedProjectUtil; -import ghidra.framework.main.datatable.ProjectDataContext; import ghidra.framework.main.datatree.*; import ghidra.framework.model.*; import ghidra.framework.plugintool.PluginTool; @@ -61,9 +60,8 @@ public class FrontEndTestEnv { // TODO make private protected TestEnv env; protected FrontEndTool frontEndTool; - protected DataTree tree; - protected DomainFolder rootFolder; - protected GTreeNode rootNode; + protected DomainFolder rootFolder; // active project root + protected DataTreeHelper projectTreeHelper; public FrontEndTestEnv() throws Exception { this(false); @@ -79,7 +77,12 @@ public class FrontEndTestEnv { startServer(); } - tree = AbstractGenericTest.findComponent(frontEndTool.getToolFrame(), DataTree.class); + // assume read-only project views are not active and only active + // project tree is present + DataTree tree = + AbstractGenericTest.findComponent(frontEndTool.getToolFrame(), DataTree.class); + projectTreeHelper = new DataTreeHelper(tree, true); + Project project = frontEndTool.getProject(); rootFolder = project.getProjectData().getRootFolder(); @@ -87,7 +90,6 @@ public class FrontEndTestEnv { rootFolder.createFile(PROGRAM_A, p, TaskMonitor.DUMMY); p.release(this); - rootNode = tree.getViewRoot(); waitForTree(); } @@ -122,7 +124,7 @@ public class FrontEndTestEnv { } public void waitForTree() { - AbstractDockingTest.waitForTree(tree); + projectTreeHelper.waitForTree(); } public DomainFolder getRootFolder() { @@ -130,11 +132,11 @@ public class FrontEndTestEnv { } public GTree getTree() { - return tree; + return projectTreeHelper.getTree(); } public GTreeNode getRootNode() { - return tree.getModelRoot(); + return projectTreeHelper.getRootNode(); } /** @@ -142,27 +144,27 @@ public class FrontEndTestEnv { * @return the default program node named {@link #PROGRAM_A} */ public DomainFileNode getProgramNode() { - return getTreeNode(PROGRAM_A); + return waitForFileNode(PROGRAM_A); } public DomainFileNode getTreeNode(String name) { - return waitForTreeNode(name); + return projectTreeHelper.waitForFileNode(name); } - public DomainFileNode waitForTreeNode(String name) { - return (DomainFileNode) AbstractGTest.waitForValue(() -> rootNode.getChild(name)); + public GTreeNode waitForTreeNode(String name) { + return projectTreeHelper.waitForTreeNode(name); } public DomainFileNode waitForFileNode(String name) { - return (DomainFileNode) AbstractGTest.waitForValue(() -> rootNode.getChild(name)); + return projectTreeHelper.waitForFileNode(name); } public DomainFolderNode waitForFolderNode(String name) { - return (DomainFolderNode) AbstractGTest.waitForValue(() -> rootNode.getChild(name)); + return projectTreeHelper.waitForFolderNode(name); } public void clearTreeSelection() { - runSwing(() -> tree.clearSelection()); + projectTreeHelper.clearTreeSelection(); } public void waitForSwing() { @@ -182,18 +184,15 @@ public class FrontEndTestEnv { } public void setTreeSelection(final TreePath[] paths) throws Exception { - tree.setSelectionPaths(paths); - waitForTree(); + projectTreeHelper.setTreeSelection(paths); } public void selectNodes(GTreeNode... nodes) { - tree.setSelectedNodes(nodes); - waitForTree(); + projectTreeHelper.selectNodes(nodes); } public void expandNode(GTreeNode node) { - tree.expandPath(node); - waitForTree(); + projectTreeHelper.expandNode(node); } public void dispose() { @@ -312,20 +311,7 @@ public class FrontEndTestEnv { } public ActionContext getDomainFileActionContext(GTreeNode... nodes) { - List fileList = new ArrayList<>(); - List folderList = new ArrayList<>(); - for (GTreeNode node : nodes) { - if (node instanceof DomainFileNode) { - fileList.add(((DomainFileNode) node).getDomainFile()); - } - else if (node instanceof DomainFolderNode) { - folderList.add(((DomainFolderNode) node).getDomainFolder()); - } - } - - return new ProjectDataContext(null, rootFolder.getProjectData(), nodes[0], folderList, - fileList, tree, true); - + return projectTreeHelper.getDomainFileActionContext(nodes); } public FrontEndTool getFrontEndTool() { @@ -447,4 +433,30 @@ public class FrontEndTestEnv { public interface ModifyProgramCallback { public void call(Program p) throws Exception; } + + /** + * Get the named READ-ONLY project view tree helper + * @param tabName project view name (should match tab name) + * @return named READ-ONLY project view tree helper or null if view tree not found + */ + public DataTreeHelper getReadOnlyProjectTreeHelper(String tabName) { + String dataTreeName = "Data Tree: " + tabName; + DataTree tree = (DataTree) AbstractGenericTest + .findComponentByName(frontEndTool.getToolFrame(), dataTreeName); + return tree != null ? new DataTreeHelper(tree, true) : null; + } + + /** + * Get the READ-ONLY project view tree helper for first view found + * @return READ-ONLY project view tree helper for first view found or null if none displayed + */ + public DataTreeHelper getFirstReadOnlyProjectTreeHelper() { + for (DataTree tree : AbstractGenericTest.findComponents(frontEndTool.getToolFrame(), + DataTree.class)) { + if (tree.getName().startsWith("Data Tree:")) { + return new DataTreeHelper(tree, true); + } + } + return null; + } } diff --git a/Ghidra/Test/IntegrationTest/src/test/java/ghidra/framework/protocol/ghidra/DefaultGhidraProtocolConnectorParseTest.java b/Ghidra/Test/IntegrationTest/src/test/java/ghidra/framework/protocol/ghidra/DefaultGhidraProtocolConnectorParseTest.java new file mode 100644 index 0000000000..2c052ed32d --- /dev/null +++ b/Ghidra/Test/IntegrationTest/src/test/java/ghidra/framework/protocol/ghidra/DefaultGhidraProtocolConnectorParseTest.java @@ -0,0 +1,128 @@ +/* ### + * 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.protocol.ghidra; + +import static org.junit.Assert.*; + +import java.net.MalformedURLException; +import java.net.URL; + +import org.junit.Test; + +import generic.test.AbstractGenericTest; + +public class DefaultGhidraProtocolConnectorParseTest extends AbstractGenericTest { + + static { + Handler.registerHandler(); + } + + @Test + public void testParseURL() throws Exception { + + DefaultGhidraProtocolConnector pp = + new DefaultGhidraProtocolConnector(new URL("ghidra://myhost")); + + try { + pp = new DefaultGhidraProtocolConnector(new URL("ghidra://myhost//")); + fail(); + } + catch (MalformedURLException e) { + // expected + } + + pp = new DefaultGhidraProtocolConnector(new URL("ghidra://myhost/")); + assertNull(pp.getRepositoryName()); + assertNull(pp.getFolderPath()); + assertNull(pp.getFolderItemName()); + assertNull(getInstanceField("itemPath", pp)); + + pp = new DefaultGhidraProtocolConnector(new URL("ghidra://myhost/repo")); + assertEquals("repo", pp.getRepositoryName()); + assertEquals("/", pp.getFolderPath()); + assertNull(pp.getFolderItemName()); + assertEquals("/", getInstanceField("itemPath", pp)); + + try { + pp = new DefaultGhidraProtocolConnector(new URL("ghidra://myhost/repo//")); + fail(); + } + catch (MalformedURLException e) { + // expected + } + + pp = new DefaultGhidraProtocolConnector(new URL("ghidra://myhost/repo/")); + assertEquals("repo", pp.getRepositoryName()); + assertEquals("/", pp.getFolderPath()); + assertNull(pp.getFolderItemName()); + assertEquals("/", getInstanceField("itemPath", pp)); + + pp = new DefaultGhidraProtocolConnector(new URL("ghidra://myhost/repo/a")); + assertEquals("repo", pp.getRepositoryName()); + assertEquals("/", pp.getFolderPath()); + assertEquals("a", pp.getFolderItemName()); + assertEquals("/a", getInstanceField("itemPath", pp)); + + try { + pp = new DefaultGhidraProtocolConnector(new URL("ghidra://myhost/repo/a//")); + fail(); + } + catch (MalformedURLException e) { + // expected + } + + try { + pp = new DefaultGhidraProtocolConnector(new URL("ghidra://myhost/repo/a///")); + fail(); + } + catch (MalformedURLException e) { + // expected + } + + pp = new DefaultGhidraProtocolConnector(new URL("ghidra://myhost/repo/a/")); + assertEquals("repo", pp.getRepositoryName()); + assertEquals("/a", pp.getFolderPath()); + assertNull(pp.getFolderItemName()); + assertEquals("/a/", getInstanceField("itemPath", pp)); + + pp = new DefaultGhidraProtocolConnector(new URL("ghidra://myhost/repo/a/b")); + assertEquals("repo", pp.getRepositoryName()); + assertEquals("/a", pp.getFolderPath()); + assertEquals("b", pp.getFolderItemName()); + assertEquals("/a/b", getInstanceField("itemPath", pp)); + + pp = new DefaultGhidraProtocolConnector(new URL("ghidra://myhost/repo/a/b/")); + assertEquals("repo", pp.getRepositoryName()); + assertEquals("/a/b", pp.getFolderPath()); + assertNull(pp.getFolderItemName()); + assertEquals("/a/b/", getInstanceField("itemPath", pp)); + + try { + pp = new DefaultGhidraProtocolConnector(new URL("ghidra://myhost/repo/a/b//")); + fail(); + } + catch (MalformedURLException e) { + // expected + } + + pp = new DefaultGhidraProtocolConnector(new URL("ghidra://myhost/repo/a/b#junk")); + assertEquals("repo", pp.getRepositoryName()); + assertEquals("/a", pp.getFolderPath()); + assertEquals("b", pp.getFolderItemName()); + assertEquals("/a/b", getInstanceField("itemPath", pp)); + } + +} diff --git a/Ghidra/Test/IntegrationTest/src/test/java/ghidra/framework/protocol/ghidra/DefaultLocalGhidraProtocolConnectorParseTest.java b/Ghidra/Test/IntegrationTest/src/test/java/ghidra/framework/protocol/ghidra/DefaultLocalGhidraProtocolConnectorParseTest.java new file mode 100644 index 0000000000..f9442e70a2 --- /dev/null +++ b/Ghidra/Test/IntegrationTest/src/test/java/ghidra/framework/protocol/ghidra/DefaultLocalGhidraProtocolConnectorParseTest.java @@ -0,0 +1,77 @@ +/* ### + * 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.protocol.ghidra; + +import static org.junit.Assert.*; + +import java.net.MalformedURLException; +import java.net.URL; + +import org.junit.Test; + +import generic.test.AbstractGenericTest; + +public class DefaultLocalGhidraProtocolConnectorParseTest extends AbstractGenericTest { + + static { + Handler.registerHandler(); + } + + @Test + public void testParseURL() throws Exception { + + DefaultLocalGhidraProtocolConnector pp = + new DefaultLocalGhidraProtocolConnector(new URL("ghidra:/C:/x/y/proj")); + assertNull(pp.getRepositoryName()); + assertEquals("/", pp.getFolderPath()); + assertNull(pp.getFolderItemName()); + assertEquals("/", getInstanceField("itemPath", pp)); + + pp = new DefaultLocalGhidraProtocolConnector(new URL("ghidra:/x/y/proj")); + assertNull(pp.getRepositoryName()); + assertEquals("/", pp.getFolderPath()); + assertNull(pp.getFolderItemName()); + assertEquals("/", getInstanceField("itemPath", pp)); + + pp = new DefaultLocalGhidraProtocolConnector(new URL("ghidra:/x/y/proj?/")); + assertNull(pp.getRepositoryName()); + assertEquals("/", pp.getFolderPath()); + assertNull(pp.getFolderItemName()); + assertEquals("/", getInstanceField("itemPath", pp)); + + pp = new DefaultLocalGhidraProtocolConnector(new URL("ghidra:/x/y/proj?/a")); + assertNull(pp.getRepositoryName()); + assertEquals("/", pp.getFolderPath()); + assertEquals("a", pp.getFolderItemName()); + assertEquals("/a", getInstanceField("itemPath", pp)); + + pp = new DefaultLocalGhidraProtocolConnector(new URL("ghidra:/x/y/proj?/a/b#ref")); + assertNull(pp.getRepositoryName()); + assertEquals("/a", pp.getFolderPath()); + assertEquals("b", pp.getFolderItemName()); + assertEquals("/a/b", getInstanceField("itemPath", pp)); + + try { + pp = + new DefaultLocalGhidraProtocolConnector(new URL("ghidra:/x/y/proj?//")); + fail(); + } + catch (MalformedURLException e) { + // expected + } + + } +} diff --git a/Ghidra/Test/IntegrationTest/src/test/java/ghidra/framework/protocol/ghidra/GhidraURLTest.java b/Ghidra/Test/IntegrationTest/src/test/java/ghidra/framework/protocol/ghidra/GhidraURLTest.java new file mode 100644 index 0000000000..72686a2b8b --- /dev/null +++ b/Ghidra/Test/IntegrationTest/src/test/java/ghidra/framework/protocol/ghidra/GhidraURLTest.java @@ -0,0 +1,499 @@ +/* ### + * 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.protocol.ghidra; + +import static org.junit.Assert.*; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; + +import org.junit.Before; +import org.junit.Test; + +import generic.test.AbstractGenericTest; +import ghidra.framework.client.*; +import ghidra.framework.model.ProjectLocator; +import ghidra.framework.protocol.ghidra.GhidraURLConnection.StatusCode; + +public class GhidraURLTest extends AbstractGenericTest { + + @Before + public void setUp() throws Exception { + Handler.registerHandler(); + } + + // makeURL(ProjectLocator) + @Test + public void testMakeLocalProjectURL() throws Exception { + ProjectLocator loc = new ProjectLocator("C:\\junk", "Test"); + URL ghidraUrl = GhidraURL.makeURL(loc); + URL url = new URL("ghidra:/C:/junk/Test"); + assertEquals(url, ghidraUrl); + assertEquals(loc, GhidraURL.getProjectStorageLocator(ghidraUrl)); + + loc = new ProjectLocator("C:\\junk\\", "Test"); + ghidraUrl = GhidraURL.makeURL(loc); + assertEquals(url, ghidraUrl); + assertEquals(loc, GhidraURL.getProjectStorageLocator(ghidraUrl)); + + loc = new ProjectLocator("/C:/junk", "Test"); + ghidraUrl = GhidraURL.makeURL(loc); + assertEquals(url, ghidraUrl); + assertEquals(loc, GhidraURL.getProjectStorageLocator(ghidraUrl)); + + loc = new ProjectLocator("/C:/junk/", "Test"); + ghidraUrl = GhidraURL.makeURL(loc); + assertEquals(url, ghidraUrl); + assertEquals(loc, GhidraURL.getProjectStorageLocator(ghidraUrl)); + + loc = new ProjectLocator("/a/b", "Test"); + ghidraUrl = GhidraURL.makeURL(loc); + url = new URL("ghidra:/a/b/Test"); + assertEquals(url, ghidraUrl); + assertEquals(loc, GhidraURL.getProjectStorageLocator(ghidraUrl)); + + loc = new ProjectLocator("/a/b/", "Test"); + ghidraUrl = GhidraURL.makeURL(loc); + assertEquals(url, ghidraUrl); + assertEquals(loc, GhidraURL.getProjectStorageLocator(ghidraUrl)); + + try { + loc = new ProjectLocator("a/b", "Test"); + fail("relative path shold not be permitted"); + } + catch (IllegalArgumentException e) { + // expected + } + } + + // makeURL(String, String) + @Test + public void testMakeLocalProjectURL2() throws Exception { + ProjectLocator loc = new ProjectLocator("C:\\junk", "Test"); + URL ghidraUrl = GhidraURL.makeURL("C:\\junk", "Test"); + URL url = new URL("ghidra:/C:/junk/Test"); + assertEquals(url, ghidraUrl); + assertEquals(loc, GhidraURL.getProjectStorageLocator(ghidraUrl)); + + loc = new ProjectLocator("C:\\junk\\", "Test"); + ghidraUrl = GhidraURL.makeURL("C:\\junk\\", "Test"); + assertEquals(url, ghidraUrl); + assertEquals(loc, GhidraURL.getProjectStorageLocator(ghidraUrl)); + + loc = new ProjectLocator("/C:/junk", "Test"); + ghidraUrl = GhidraURL.makeURL("/C:/junk", "Test"); + assertEquals(url, ghidraUrl); + assertEquals(loc, GhidraURL.getProjectStorageLocator(ghidraUrl)); + + loc = new ProjectLocator("/C:/junk/", "Test"); + ghidraUrl = GhidraURL.makeURL("/C:/junk/", "Test"); + assertEquals(url, ghidraUrl); + assertEquals(loc, GhidraURL.getProjectStorageLocator(ghidraUrl)); + + loc = new ProjectLocator("/a/b", "Test"); + ghidraUrl = GhidraURL.makeURL("/a/b", "Test"); + url = new URL("ghidra:/a/b/Test"); + assertEquals(url, ghidraUrl); + assertEquals(loc, GhidraURL.getProjectStorageLocator(ghidraUrl)); + + loc = new ProjectLocator("/a/b/", "Test"); + ghidraUrl = GhidraURL.makeURL("/a/b/", "Test"); + assertEquals(url, ghidraUrl); + assertEquals(loc, GhidraURL.getProjectStorageLocator(ghidraUrl)); + + try { + ghidraUrl = GhidraURL.makeURL("a/b/", "Test"); + fail("relative path shold not be permitted"); + } + catch (IllegalArgumentException e) { + // expected + } + } + + // makeURL(ProjectLocator, String, String) + @Test + public void testMakeLocalProjectFileURL() throws Exception { + ProjectLocator loc = new ProjectLocator("C:\\junk", "Test"); + + URL ghidraUrl = GhidraURL.makeURL(loc, "/a", "ref"); + URL url = new URL("ghidra:/C:/junk/Test?/a#ref"); + assertEquals(url, ghidraUrl); + assertEquals(loc, GhidraURL.getProjectStorageLocator(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL(loc, "/a/", "ref"); + url = new URL("ghidra:/C:/junk/Test?/a/#ref"); + assertEquals(url, ghidraUrl); + + try { + ghidraUrl = GhidraURL.makeURL(loc, "a/b", "ref"); + fail("relative path shold not be permitted"); + } + catch (IllegalArgumentException e) { + // expected + } + } + + // makeURL(String, String, String, String) + @Test + public void testMakeLocalProjectFileURL2() throws Exception { + ProjectLocator loc = new ProjectLocator("C:\\junk", "Test"); + + URL ghidraUrl = GhidraURL.makeURL("C:\\junk", "Test", "/a", "ref"); + URL url = new URL("ghidra:/C:/junk/Test?/a#ref"); + assertEquals(url, ghidraUrl); + assertEquals(loc, GhidraURL.getProjectStorageLocator(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL("C:\\junk", "Test", "/a/", "ref"); + url = new URL("ghidra:/C:/junk/Test?/a/#ref"); + assertEquals(url, ghidraUrl); + + try { + ghidraUrl = GhidraURL.makeURL("C:\\junk", "Test", "a/b", "ref"); + fail("relative path shold not be permitted"); + } + catch (IllegalArgumentException e) { + // expected + } + } + + // makeURL(String, int, String) + @Test + public void testMakeServerRepoURL() throws Exception { + URL ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test"); + URL url = new URL("ghidra", "localhost", 123, "/Test"); + assertEquals(url, ghidraUrl); + } + + // makeURL(String, int, String, String) + @Test + public void testMakeServerRepoFileURL() throws Exception { + URL ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test", "/foo/"); + URL url = new URL("ghidra", "localhost", 123, "/Test/foo/"); + assertEquals(url, ghidraUrl); + + ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test", "/foo"); + url = new URL("ghidra", "localhost", 123, "/Test/foo"); + assertEquals(url, ghidraUrl); + + } + + // makeURL(String, int, String, String, String, String) + @Test + public void testMakeServerRepoFileURL2() throws Exception { + URL ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test", "/foo", null, null); + URL url = new URL("ghidra", "localhost", 123, "/Test/foo/"); + assertEquals(url, ghidraUrl); + + ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test", "/foo/", null, null); + assertEquals(url, ghidraUrl); + + ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test", "/foo", "bar", "ref"); + url = new URL("ghidra", "localhost", 123, "/Test/foo/bar#ref"); + assertEquals(url, ghidraUrl); + + ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test", "/foo/", "bar", "ref"); + assertEquals(url, ghidraUrl); + } + +// makeURL(String, int, String, String) + @Test + public void testMakeServerRepoFileURL3() throws Exception { + URL ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test", "/foo"); + URL url = new URL("ghidra", "localhost", 123, "/Test/foo"); + assertEquals(url, ghidraUrl); + + ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test", "/foo/"); + url = new URL("ghidra", "localhost", 123, "/Test/foo/"); + assertEquals(url, ghidraUrl); + + ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test", "/foo/bar", "ref"); + url = new URL("ghidra", "localhost", 123, "/Test/foo/bar#ref"); + assertEquals(url, ghidraUrl); + + ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test", "/foo/bar"); + url = new URL("ghidra", "localhost", 123, "/Test/foo/bar"); + assertEquals(url, ghidraUrl); + } + + // getProjectStorageLocator(URL) + @Test + public void testGetProjectStorageLocator() throws Exception { + ProjectLocator loc = new ProjectLocator("C:\\junk", "Test"); + URL ghidraUrl = GhidraURL.makeURL(loc); + assertEquals(loc, GhidraURL.getProjectStorageLocator(ghidraUrl)); + + loc = new ProjectLocator("C:\\junk\\", "Test"); + ghidraUrl = GhidraURL.makeURL(loc); + assertEquals(loc, GhidraURL.getProjectStorageLocator(ghidraUrl)); + + loc = new ProjectLocator("/C:/junk", "Test"); + ghidraUrl = GhidraURL.makeURL(loc); + assertEquals(loc, GhidraURL.getProjectStorageLocator(ghidraUrl)); + + loc = new ProjectLocator("/C:/junk/", "Test"); + ghidraUrl = GhidraURL.makeURL(loc); + assertEquals(loc, GhidraURL.getProjectStorageLocator(ghidraUrl)); + + loc = new ProjectLocator("/a/b", "Test"); + ghidraUrl = GhidraURL.makeURL(loc); + assertEquals(loc, GhidraURL.getProjectStorageLocator(ghidraUrl)); + + loc = new ProjectLocator("/a/b/", "Test"); + ghidraUrl = GhidraURL.makeURL(loc); + assertEquals(loc, GhidraURL.getProjectStorageLocator(ghidraUrl)); + } + + // isLocalProjectURL(URL) + @Test + public void testIsLocalProjectURL() throws Exception { + URL ghidraUrl = GhidraURL.makeURL("C:\\junk", "Test"); + assertTrue(GhidraURL.isLocalProjectURL(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL("C:\\junk", "Test", "/a", "ref"); + assertTrue(GhidraURL.isLocalProjectURL(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL("/x/y", "Test", "/a", "ref"); + assertTrue(GhidraURL.isLocalProjectURL(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test"); + assertFalse(GhidraURL.isLocalProjectURL(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test", "/foo/", "bar", "ref"); + assertFalse(GhidraURL.isLocalProjectURL(ghidraUrl)); + } + + // isServerRepositoryURL(URL) + @Test + public void testIsServerRepositoryURL() throws Exception { + URL ghidraUrl = GhidraURL.makeURL("C:\\junk", "Test"); + assertNull(GhidraURL.getRepositoryName(ghidraUrl)); + assertFalse(GhidraURL.isServerRepositoryURL(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL("C:\\junk", "Test", "/a", "ref"); + assertNull(GhidraURL.getRepositoryName(ghidraUrl)); + assertFalse(GhidraURL.isServerRepositoryURL(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL("/x/y", "Test", "/a", "ref"); + assertNull(GhidraURL.getRepositoryName(ghidraUrl)); + assertFalse(GhidraURL.isServerRepositoryURL(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test"); + assertEquals("Test", GhidraURL.getRepositoryName(ghidraUrl)); + assertTrue(GhidraURL.isServerRepositoryURL(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test", "/foo/", "bar", "ref"); + assertEquals("Test", GhidraURL.getRepositoryName(ghidraUrl)); + assertTrue(GhidraURL.isServerRepositoryURL(ghidraUrl)); + + ghidraUrl = new URL("ghidra", "localhost", 123, ""); + assertNull(GhidraURL.getRepositoryName(ghidraUrl)); + assertFalse(GhidraURL.isServerRepositoryURL(ghidraUrl)); + } + + // isServerURL(URL) + @Test + public void testIsServerURL() throws Exception { + URL ghidraUrl = GhidraURL.makeURL("C:\\junk", "Test"); + assertFalse(GhidraURL.isServerURL(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL("C:\\junk", "Test", "/a", "ref"); + assertFalse(GhidraURL.isServerURL(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL("/x/y", "Test", "/a", "ref"); + assertFalse(GhidraURL.isServerURL(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test"); + assertTrue(GhidraURL.isServerURL(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test", "/foo/", "bar", "ref"); + assertTrue(GhidraURL.isServerURL(ghidraUrl)); + + ghidraUrl = new URL("ghidra", "localhost", 123, ""); + assertTrue(GhidraURL.isServerURL(ghidraUrl)); + } + + // toURL(String) + @Test + public void testToURL() throws Exception { + URL ghidraUrl = GhidraURL.makeURL("C:\\junk", "Test"); + assertEquals(ghidraUrl, GhidraURL.toURL("C:\\junk\\Test")); + assertEquals(ghidraUrl, GhidraURL.toURL("/C:/junk/Test")); + assertEquals(ghidraUrl, GhidraURL.toURL("C:/junk/Test")); + assertEquals(ghidraUrl, GhidraURL.toURL("ghidra:/C:/junk/Test")); + assertEquals(ghidraUrl, GhidraURL.toURL(ghidraUrl.toString())); + assertEquals(ghidraUrl, GhidraURL.toURL(GhidraURL.getDisplayString(ghidraUrl))); + + ghidraUrl = GhidraURL.makeURL("C:\\junk", "Test", "/a", "ref"); + assertEquals(ghidraUrl, GhidraURL.toURL("ghidra:/C:/junk/Test?/a#ref")); + assertEquals(ghidraUrl, GhidraURL.toURL(ghidraUrl.toString())); + assertEquals(ghidraUrl, GhidraURL.toURL(GhidraURL.getDisplayString(ghidraUrl))); + + ghidraUrl = GhidraURL.makeURL("C:\\junk", "Test", "/a/", "ref"); + assertEquals(ghidraUrl, GhidraURL.toURL("ghidra:/C:/junk/Test?/a/#ref")); + assertEquals(ghidraUrl, GhidraURL.toURL(ghidraUrl.toString())); + assertEquals(ghidraUrl, GhidraURL.toURL(GhidraURL.getDisplayString(ghidraUrl))); + + ghidraUrl = GhidraURL.makeURL("/x/y", "Test", "/a/", "ref"); + assertEquals(ghidraUrl, GhidraURL.toURL("ghidra:/x/y/Test?/a/#ref")); + assertEquals(ghidraUrl, GhidraURL.toURL(ghidraUrl.toString())); + assertEquals(ghidraUrl, GhidraURL.toURL(GhidraURL.getDisplayString(ghidraUrl))); + + ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test", "/foo", "bar", "ref"); + assertEquals(ghidraUrl, GhidraURL.toURL(ghidraUrl.toString())); + assertEquals(ghidraUrl, GhidraURL.toURL(GhidraURL.getDisplayString(ghidraUrl))); + + } + + @Test + public void testGetProjectURL() throws Exception { + + URL ghidraUrl = GhidraURL.makeURL("C:\\junk", "Test"); + assertEquals(new URL("ghidra:/C:/junk/Test"), GhidraURL.getProjectURL(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL("C:\\junk", "Test", "/a", "ref"); + assertEquals(new URL("ghidra:/C:/junk/Test"), GhidraURL.getProjectURL(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL("/x/y", "Test", "/a", "ref"); + assertEquals(new URL("ghidra:/x/y/Test"), GhidraURL.getProjectURL(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test"); + assertEquals(new URL("ghidra://localhost:123/Test"), GhidraURL.getProjectURL(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test", "/foo/", "bar", "ref"); + assertEquals(new URL("ghidra://localhost:123/Test"), GhidraURL.getProjectURL(ghidraUrl)); + + ghidraUrl = new URL("ghidra", "localhost", 123, ""); + try { + GhidraURL.getProjectURL(ghidraUrl); + fail("Expected IllegalArgumentException"); + } + catch (IllegalArgumentException e) { + // expected + } + + } + + // getDisplayString(URL) + @Test + public void testGetDisplayString() throws Exception { + URL ghidraUrl = GhidraURL.makeURL("C:\\junk", "Test"); + assertEquals("C:\\junk\\Test", GhidraURL.getDisplayString(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL("C:\\junk", "Test", "/a", "ref"); + assertEquals("ghidra:/C:/junk/Test?/a#ref", GhidraURL.getDisplayString(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL("C:\\junk", "Test", "/a/", "ref"); + assertEquals("ghidra:/C:/junk/Test?/a/#ref", GhidraURL.getDisplayString(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL("/x/y", "Test", "/a/", "ref"); + assertEquals("ghidra:/x/y/Test?/a/#ref", GhidraURL.getDisplayString(ghidraUrl)); + + ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test", "/foo", "bar", "ref"); + assertEquals(ghidraUrl.toString(), GhidraURL.getDisplayString(ghidraUrl)); + + } + + // getNormalizedURL(URL) + @Test + public void testNormalizedURL() throws Exception { + URL ghidraUrl = GhidraURL.makeURL("C:\\junk", "Test"); + assertEquals("ghidra:/C:/junk/Test", GhidraURL.getNormalizedURL(ghidraUrl).toString()); + + ghidraUrl = GhidraURL.makeURL("C:\\junk", "Test", "/a", "ref"); + assertEquals("ghidra:/C:/junk/Test?/a", GhidraURL.getNormalizedURL(ghidraUrl).toString()); + + ghidraUrl = GhidraURL.makeURL("C:\\junk", "Test", "/a/", "ref"); + assertEquals("ghidra:/C:/junk/Test?/a/", GhidraURL.getNormalizedURL(ghidraUrl).toString()); + + ghidraUrl = GhidraURL.makeURL("/x/y", "Test", "/a/", "ref"); + assertEquals("ghidra:/x/y/Test?/a/", GhidraURL.getNormalizedURL(ghidraUrl).toString()); + + ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test", "/foo", "bar", "ref"); + assertEquals("ghidra://127.0.0.1:123/Test/foo/bar", + GhidraURL.getNormalizedURL(ghidraUrl).toString()); + } + + @Test + public void testTransientProjectURL() throws Exception { + // Dummy class implementations (see below) are used to stub objects required to establish + // transient project for URL verification testing only + URL ghidraUrl = GhidraURL.makeURL("localhost", 123, "Test"); + DummyGhidraProtocolConnector dummyRepoConnector = + new DummyGhidraProtocolConnector(ghidraUrl); + TransientProjectManager transientProjectManager = + TransientProjectManager.getTransientProjectManager(); + try { + TransientProjectData transientProject = + transientProjectManager.getTransientProject(dummyRepoConnector, true); + ProjectLocator projectLocator = transientProject.getProjectLocator(); + assertTrue(GhidraURL.isServerRepositoryURL(projectLocator.getURL())); + } + finally { + transientProjectManager.dispose(); + } + } + + private static class DummyGhidraProtocolConnector extends GhidraProtocolConnector { + + private URL repositoryURL; + private DummyRepositoryAdapter repoAdapter; + + DummyGhidraProtocolConnector(URL repositoryURL) throws MalformedURLException { + super(repositoryURL); + this.repositoryURL = repositoryURL; + repoAdapter = new DummyRepositoryAdapter(); + } + + @Override + protected URL getRepositoryRootGhidraURL() { + return repositoryURL; + } + + @Override + public StatusCode connect(boolean readOnly) throws IOException { + return StatusCode.OK; + } + + @Override + public boolean isReadOnly() throws NotConnectedException { + return true; + } + + @Override + public RepositoryAdapter getRepositoryAdapter() { + return repoAdapter; + } + + } + + private static class DummyRepositoryAdapter extends RepositoryAdapter { + DummyRepositoryAdapter() { + super(new DummyRepositoryServerAdapter(), "test"); + } + + @Override + public boolean isConnected() { + return true; + } + } + + private static class DummyRepositoryServerAdapter extends RepositoryServerAdapter { + DummyRepositoryServerAdapter() { + super(null, null); + } + } +}