From afb235c20fe6360db52cd3e2ea05ed1346b1ff53 Mon Sep 17 00:00:00 2001 From: gibson9583 Date: Mon, 21 Sep 2026 14:06:19 -0400 Subject: [PATCH 1/4] feat: decouple extension compatibility from engine releases Allow plugins and connectors to declare a minimum OIE extension API version independently of product releases. Preserve release matching for extensions that do not opt in. Share compatibility checks across installation and launcher loading, with regression tests and the compatibility contract. Validation: 699 tests, 44 ZIP installation cases, launcher filtering, and isolated packaged-helper checks passed. Signed-off-by: gibson9583 --- docs/extension-compatibility.md | 55 +++++++++++ server/build.gradle | 2 + .../client/core/ExtensionCompatibility.java | 68 +++++++++++++ .../com/mirth/connect/model/MetaData.java | 10 ++ .../mirth/connect/server/ExtensionLoader.java | 23 ++--- .../server/launcher/MirthLauncher.java | 37 ++++--- .../core/ExtensionCompatibilityTest.java | 63 ++++++++++++ .../connect/model/ExtensionMetaDataTest.java | 60 ++++++++++++ .../launcher/ExtensionCompatibilityTest.java | 97 +++++++++++++++++++ 9 files changed, 384 insertions(+), 31 deletions(-) create mode 100644 docs/extension-compatibility.md create mode 100644 server/src/main/java/com/mirth/connect/client/core/ExtensionCompatibility.java create mode 100644 server/src/test/java/com/mirth/connect/client/core/ExtensionCompatibilityTest.java create mode 100644 server/src/test/java/com/mirth/connect/model/ExtensionMetaDataTest.java create mode 100644 server/src/test/java/com/mirth/connect/server/launcher/ExtensionCompatibilityTest.java diff --git a/docs/extension-compatibility.md b/docs/extension-compatibility.md new file mode 100644 index 0000000000..d0d91a0a5e --- /dev/null +++ b/docs/extension-compatibility.md @@ -0,0 +1,55 @@ +# Extension compatibility + +Custom plugins and connectors can opt into an OIE extension API version instead +of listing every compatible engine release. OIE currently provides extension +API **1.0.0**, versioned independently of the product release. + +## Updating an extension + +Build and test the extension against OIE. Add this element directly under the +root of each applicable `plugin.xml`, `source.xml`, and `destination.xml`: + +```xml +1.0.0 +``` + +Keep the extension's own `pluginVersion`. Existing dependencies, Java packages, +and plugin/connector implementations do not need to change for this mechanism. +The same archive can then install and load on subsequent OIE releases providing +a compatible extension API. Continue testing against supported engine releases; +metadata declares compatibility, it does not verify binary or runtime behavior. + +When this element declares a non-null value, it replaces the `mirthVersion` +check. The engine's API version must be at least the requested version and have the same major +number: API 1.2.0 accepts a minimum of 1.0.0 or 1.2.0, but rejects 1.3.0 and +2.0.0. Versions must contain three nonnegative decimal integers separated by +periods, with no leading zeroes, suffixes, or wildcards; each component must fit +a Java signed integer. Surrounding whitespace is allowed. Empty or malformed +requirements reject the extension, even if `mirthVersion` matches. + +Omit the element (or use XStream's explicit `class="null"` representation) to +retain the existing `mirthVersion` behavior: a comma-separated list of exact +engine releases, ignoring the engine's fourth build component. +Bundled extensions continue using this existing mechanism. Older engines that +predate this feature cannot read the new metadata field; retaining `mirthVersion` +alongside it does not provide backward compatibility with those engines. + +## Maintaining the API version + +`ExtensionCompatibility.API_VERSION` is a single compatibility contract covering +OIE's existing extension-facing classes across the server, shared models, and +Administrator client. It is deliberately separate from the product version and +does not require new JARs or relocated classes. + +- Leave it unchanged for product releases that preserve the extension contract. +- Increase the minor version for compatible API additions; extensions using + those additions must declare the new minimum. +- Increase the patch version for compatible contract fixes when extensions need + to require that fix. +- Increase the major version for incompatible changes, including incompatible + changes to dependencies exposed through the extension API. Extensions must be + reviewed and updated before declaring support for that major version. + +Extensions depending on implementation details outside that contract should +continue declaring exact engine releases. Compatibility checks run during +installation, launcher classpath construction, and engine metadata loading. diff --git a/server/build.gradle b/server/build.gradle index a8c38cb94c..a33a94a5f1 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -678,6 +678,8 @@ def launcherJar = tasks.register('launcherJar', Jar) { archiveFileName = 'mirth-server-launcher.jar' destinationDirectory = file('setup') from(sourceSets.main.output.classesDirs) { + // The launcher checks compatibility before loading mirth-client-core.jar. + include 'com/mirth/connect/client/core/ExtensionCompatibility.class' include 'com/mirth/connect/server/launcher/**' include 'com/mirth/connect/server/extprops/**' } diff --git a/server/src/main/java/com/mirth/connect/client/core/ExtensionCompatibility.java b/server/src/main/java/com/mirth/connect/client/core/ExtensionCompatibility.java new file mode 100644 index 0000000000..e9b77857b8 --- /dev/null +++ b/server/src/main/java/com/mirth/connect/client/core/ExtensionCompatibility.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) Open Integration Engine contributors. + * Licensed under the Mozilla Public License 2.0. + */ + +package com.mirth.connect.client.core; + +import java.util.regex.Pattern; + +/** Shared by the launcher and engine; must not depend on engine or third-party classes. */ +public final class ExtensionCompatibility { + // Independent of the product release. See docs/extension-compatibility.md before changing. + public static final String API_VERSION = "1.0.0"; + + private static final Pattern API_VERSION_PATTERN = + Pattern.compile("(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)"); + + private ExtensionCompatibility() {} + + public static boolean isCompatible(String mirthVersions, String minExtensionApiVersion, + String serverVersion) { + if (minExtensionApiVersion != null) { + // An invalid explicit requirement must never fall back to the legacy release check. + return isApiCompatible(minExtensionApiVersion, API_VERSION); + } + + if (mirthVersions == null || serverVersion == null) { + return false; + } + + // Preserve legacy matching, including ignoring the server's fourth (build) component. + if (serverVersion.split("\\.").length == 4) { + serverVersion = serverVersion.substring(0, serverVersion.lastIndexOf('.')); + } + for (String version : mirthVersions.split(",")) { + if (version.trim().equals(serverVersion)) { + return true; + } + } + return false; + } + + static boolean isApiCompatible(String minimumVersion, String currentVersion) { + int[] minimum = parseApiVersion(minimumVersion); + int[] current = parseApiVersion(currentVersion); + return minimum != null && current != null && minimum[0] == current[0] + && (current[1] > minimum[1] + || (current[1] == minimum[1] && current[2] >= minimum[2])); + } + + private static int[] parseApiVersion(String version) { + if (version == null) { + return null; + } + + String normalizedVersion = version.trim(); + if (!API_VERSION_PATTERN.matcher(normalizedVersion).matches()) { + return null; + } + + String[] parts = normalizedVersion.split("\\."); + try { + return new int[] { Integer.parseInt(parts[0]), Integer.parseInt(parts[1]), Integer.parseInt(parts[2]) }; + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/server/src/main/java/com/mirth/connect/model/MetaData.java b/server/src/main/java/com/mirth/connect/model/MetaData.java index a0b59bdff9..dbaec4a6fe 100644 --- a/server/src/main/java/com/mirth/connect/model/MetaData.java +++ b/server/src/main/java/com/mirth/connect/model/MetaData.java @@ -27,6 +27,7 @@ public abstract class MetaData { private String name; private String author; private String mirthVersion; + private String minExtensionApiVersion; private String pluginVersion; private String url; private String description; @@ -72,6 +73,15 @@ public void setMirthVersion(String mirthVersion) { this.mirthVersion = mirthVersion; } + /** Minimum OIE extension API version; when present, replaces the release version check. */ + public String getMinExtensionApiVersion() { + return minExtensionApiVersion; + } + + public void setMinExtensionApiVersion(String minExtensionApiVersion) { + this.minExtensionApiVersion = minExtensionApiVersion; + } + public String getPluginVersion() { return pluginVersion; } diff --git a/server/src/main/java/com/mirth/connect/server/ExtensionLoader.java b/server/src/main/java/com/mirth/connect/server/ExtensionLoader.java index 9af71f74d7..e9c3869057 100644 --- a/server/src/main/java/com/mirth/connect/server/ExtensionLoader.java +++ b/server/src/main/java/com/mirth/connect/server/ExtensionLoader.java @@ -25,12 +25,12 @@ import org.apache.commons.io.filefilter.FileFilterUtils; import org.apache.commons.io.filefilter.IOFileFilter; import org.apache.commons.io.filefilter.NameFileFilter; -import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import com.google.inject.Inject; +import com.mirth.connect.client.core.ExtensionCompatibility; import com.mirth.connect.client.core.PropertiesConfigurationUtil; import com.mirth.connect.model.ConnectorMetaData; import com.mirth.connect.model.MetaData; @@ -149,22 +149,11 @@ public boolean isExtensionCompatible(MetaData metaData) { return false; } - String[] extensionMirthVersions = metaData.getMirthVersion().split(","); - - logger.debug("checking extension \"" + metaData.getName() + "\" version compatability: versions=" + ArrayUtils.toString(extensionMirthVersions) + ", server=" + serverMirthVersion); - - // if there is no build version, just use the patch version - if (serverMirthVersion.split("\\.").length == 4) { - serverMirthVersion = serverMirthVersion.substring(0, serverMirthVersion.lastIndexOf('.')); - } - - for (int i = 0; i < extensionMirthVersions.length; i++) { - if (extensionMirthVersions[i].trim().equals(serverMirthVersion)) { - return true; - } - } - - return false; + logger.debug("Checking extension \"{}\" compatibility: releases={}, minimum API={}, server={}, API={}", + metaData.getName(), metaData.getMirthVersion(), metaData.getMinExtensionApiVersion(), + serverMirthVersion, ExtensionCompatibility.API_VERSION); + return ExtensionCompatibility.isCompatible(metaData.getMirthVersion(), + metaData.getMinExtensionApiVersion(), serverMirthVersion); } /** diff --git a/server/src/main/java/com/mirth/connect/server/launcher/MirthLauncher.java b/server/src/main/java/com/mirth/connect/server/launcher/MirthLauncher.java index 33458f2e08..5fae8beaad 100644 --- a/server/src/main/java/com/mirth/connect/server/launcher/MirthLauncher.java +++ b/server/src/main/java/com/mirth/connect/server/launcher/MirthLauncher.java @@ -31,8 +31,10 @@ import org.apache.commons.io.filefilter.WildcardFileFilter; import org.w3c.dom.Document; import org.w3c.dom.Element; +import org.w3c.dom.Node; import org.w3c.dom.NodeList; +import com.mirth.connect.client.core.ExtensionCompatibility; import com.mirth.connect.server.extprops.ExtensionStatuses; import com.mirth.connect.server.extprops.LoggerWrapper; @@ -250,7 +252,7 @@ private static void addExtensionsToClasspath(List urls, String currentVersi Element rootElement = document.getDocumentElement(); boolean enabled = extensionStatuses.isEnabled(rootElement.getElementsByTagName("name").item(0).getTextContent()); - boolean compatible = isExtensionCompatible(rootElement.getElementsByTagName("mirthVersion").item(0).getTextContent(), currentVersion); + boolean compatible = isExtensionCompatible(rootElement, currentVersion); // Only add libraries from extensions that are not disabled and are compatible with the current version if (enabled && compatible) { @@ -282,23 +284,30 @@ private static void addExtensionsToClasspath(List urls, String currentVersi } } - private static boolean isExtensionCompatible(String extensionVersion, String currentVersion) { - if (extensionVersion != null) { - String[] extensionMirthVersions = extensionVersion.split(","); - - // If there is no build version, just use the patch version - if (currentVersion.split("\\.").length == 4) { - currentVersion = currentVersion.substring(0, currentVersion.lastIndexOf('.')); - } + static boolean isExtensionCompatible(Element metadata, String currentVersion) { + return ExtensionCompatibility.isCompatible(getMetadataValue(metadata, "mirthVersion"), + getMetadataValue(metadata, "minExtensionApiVersion"), currentVersion); + } - for (int i = 0; i < extensionMirthVersions.length; i++) { - if (extensionMirthVersions[i].trim().equals(currentVersion)) { - return true; + private static String getMetadataValue(Element metadata, String name) { + for (Node child = metadata.getFirstChild(); child != null; child = child.getNextSibling()) { + if (child instanceof Element && name.equals(child.getNodeName())) { + // Match XStream's null and scalar text handling without loading XStream here. + Element element = (Element) child; + String type = element.hasAttribute("resolves-to") ? element.getAttribute("resolves-to") : element.getAttribute("class"); + if ("null".equals(type) || "com.thoughtworks.xstream.mapper.Mapper$Null".equals(type)) { + return null; } + StringBuilder text = new StringBuilder(); + for (Node value = child.getFirstChild(); value != null; value = value.getNextSibling()) { + if (value.getNodeType() == Node.TEXT_NODE || value.getNodeType() == Node.CDATA_SECTION_NODE) { + text.append(value.getNodeValue()); + } + } + return text.toString(); } } - - return false; + return null; } private static void createAppdataDir(Properties mirthProperties) { diff --git a/server/src/test/java/com/mirth/connect/client/core/ExtensionCompatibilityTest.java b/server/src/test/java/com/mirth/connect/client/core/ExtensionCompatibilityTest.java new file mode 100644 index 0000000000..4691805501 --- /dev/null +++ b/server/src/test/java/com/mirth/connect/client/core/ExtensionCompatibilityTest.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) Open Integration Engine contributors. + * Licensed under the Mozilla Public License 2.0. + */ + +package com.mirth.connect.client.core; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class ExtensionCompatibilityTest { + + @Test + public void legacyMetadataKeepsExactReleaseMatching() { + assertTrue(ExtensionCompatibility.isCompatible("4.5.1, 4.5.2 ", null, "4.5.2")); + assertTrue(ExtensionCompatibility.isCompatible("4.5.2", null, "4.5.2.123")); + assertFalse(ExtensionCompatibility.isCompatible("4.5.2", null, "4.5.3")); + assertFalse(ExtensionCompatibility.isCompatible("4.5.2.123", null, "4.5.2.123")); + assertFalse(ExtensionCompatibility.isCompatible("4.5.*", null, "4.5.2")); + assertFalse(ExtensionCompatibility.isCompatible(null, null, "4.5.2")); + assertFalse(ExtensionCompatibility.isCompatible("", null, "4.5.2")); + assertFalse(ExtensionCompatibility.isCompatible("4.5.2", null, null)); + } + + @Test + public void apiLockDoesNotDependOnTheProductRelease() { + assertTrue(ExtensionCompatibility.isCompatible("4.5.2", "1.0.0", "99.0.0")); + assertTrue(ExtensionCompatibility.isCompatible(null, " 1.0.0 ", null)); + } + + @Test + public void presentApiLockNeverFallsBackToMatchingLegacyRelease() { + for (String minimum : new String[] { "", " ", "invalid", "1.0.1", "2.0.0" }) { + assertFalse(minimum, ExtensionCompatibility.isCompatible("4.5.2", minimum, "4.5.2")); + } + } + + @Test + public void apiComparisonIsNumericAndRequiresTheSameMajorVersion() { + assertTrue(ExtensionCompatibility.isApiCompatible("1.2.3", "1.2.3")); + assertTrue(ExtensionCompatibility.isApiCompatible("1.2.3", "1.2.10")); + assertTrue(ExtensionCompatibility.isApiCompatible("1.2.99", "1.10.0")); + assertTrue(ExtensionCompatibility.isApiCompatible(" 1.2.3 ", " 1.2.4 ")); + assertTrue(ExtensionCompatibility.isApiCompatible("1.0.0", "1.2147483647.2147483647")); + assertFalse(ExtensionCompatibility.isApiCompatible("1.2.4", "1.2.3")); + assertFalse(ExtensionCompatibility.isApiCompatible("1.10.0", "1.2.99")); + assertFalse(ExtensionCompatibility.isApiCompatible("1.0.0", "2.0.0")); + assertFalse(ExtensionCompatibility.isApiCompatible("2.0.0", "1.0.0")); + } + + @Test + public void invalidApiVersionsFailClosedOnEitherSide() { + for (String invalid : new String[] { null, "", " ", "1", "1.0", "1.0.0.0", "1.0.0.", + "01.0.0", "1.00.0", "1.0.00", "-1.0.0", "1.-1.0", "1.0.-1", "+1.0.0", + "1.0.0-beta", "1.0.0+build", "1. 0.0", "1.0.*", "1.0.0,1.0.1", + "2147483648.0.0", "1.2147483648.0", "1.0.2147483648" }) { + assertFalse(String.valueOf(invalid), ExtensionCompatibility.isApiCompatible(invalid, "1.0.0")); + assertFalse(String.valueOf(invalid), ExtensionCompatibility.isApiCompatible("1.0.0", invalid)); + } + } +} diff --git a/server/src/test/java/com/mirth/connect/model/ExtensionMetaDataTest.java b/server/src/test/java/com/mirth/connect/model/ExtensionMetaDataTest.java new file mode 100644 index 0000000000..c986dbfc10 --- /dev/null +++ b/server/src/test/java/com/mirth/connect/model/ExtensionMetaDataTest.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) Open Integration Engine contributors. + * Licensed under the Mozilla Public License 2.0. + */ + +package com.mirth.connect.model; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import com.mirth.connect.model.converters.ObjectXMLSerializer; + +public class ExtensionMetaDataTest { + private final ObjectXMLSerializer serializer = new ObjectXMLSerializer(getClass().getClassLoader()); + + @Test + public void apiLockRoundTripsForPluginsAndConnectors() { + for (MetaData metadata : new MetaData[] { new PluginMetaData(), new ConnectorMetaData() }) { + metadata.setName("Custom extension"); + metadata.setMinExtensionApiVersion("1.0.0"); + String xml = serializer.serialize(metadata); + assertTrue(xml.contains("1.0.0")); + MetaData restored = serializer.deserialize(xml, MetaData.class); + assertEquals(metadata.getClass(), restored.getClass()); + assertEquals("1.0.0", restored.getMinExtensionApiVersion()); + assertNull(restored.getMirthVersion()); + } + } + + @Test + public void legacyAndEmptyApiLocksRemainDistinctAfterDeserialization() { + for (String root : new String[] { "pluginMetaData", "connectorMetaData" }) { + String legacyXml = "<" + root + ">4.5.2"; + MetaData legacy = serializer.deserialize(legacyXml, MetaData.class); + assertNull(legacy.getMinExtensionApiVersion()); + assertEquals("4.5.2", legacy.getMirthVersion()); + String emptyXml = "<" + root + ">"; + assertEquals("", serializer.deserialize(emptyXml, MetaData.class).getMinExtensionApiVersion()); + } + } + + @Test + public void explicitNullApiLocksRoundTripAsAbsentForPluginsAndConnectors() { + for (String root : new String[] { "pluginMetaData", "connectorMetaData" }) { + String xml = "<" + root + ">4.5.2" + + ""; + MetaData metadata = serializer.deserialize(xml, MetaData.class); + assertNull(metadata.getMinExtensionApiVersion()); + String serialized = serializer.serialize(metadata); + assertFalse(serialized.contains("minExtensionApiVersion")); + MetaData restored = serializer.deserialize(serialized, MetaData.class); + assertNull(restored.getMinExtensionApiVersion()); + assertEquals("4.5.2", restored.getMirthVersion()); + } + } +} diff --git a/server/src/test/java/com/mirth/connect/server/launcher/ExtensionCompatibilityTest.java b/server/src/test/java/com/mirth/connect/server/launcher/ExtensionCompatibilityTest.java new file mode 100644 index 0000000000..867abb70c5 --- /dev/null +++ b/server/src/test/java/com/mirth/connect/server/launcher/ExtensionCompatibilityTest.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) Open Integration Engine contributors. + * Licensed under the Mozilla Public License 2.0. + */ + +package com.mirth.connect.server.launcher; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mockStatic; + +import java.io.ByteArrayInputStream; +import java.io.FileNotFoundException; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; + +import javax.xml.parsers.DocumentBuilderFactory; + +import org.junit.Test; +import org.mockito.MockedStatic; +import org.w3c.dom.Element; +import org.xml.sax.InputSource; + +import com.mirth.connect.model.MetaData; +import com.mirth.connect.model.PluginMetaData; +import com.mirth.connect.model.converters.ObjectXMLSerializer; +import com.mirth.connect.server.ExtensionLoader; +import com.mirth.connect.server.util.ResourceUtil; + +public class ExtensionCompatibilityTest { + + @Test + public void launcherAndLoaderAgreeForPluginAndConnectorMetadata() throws Exception { + ObjectXMLSerializer serializer = new ObjectXMLSerializer(getClass().getClassLoader()); + Object[][] cases = { + { "4.5.1, 4.5.2 ", "4.5.2.123", true }, + { "4.5.2", "99.0.0", false }, + { "4.5.21.0.0", "99.0.0", true }, + { " 1.0.0 ", "99.0.0", true }, + { "4.5.2", "4.5.2", false }, + { "4.5.2invalid", "4.5.2", false }, + { "4.5.21.0.1", "4.5.2", false }, + { "4.5.2", "4.5.2", true }, + { "4.5.2", "99.0.0", false }, + { "4.5.22.0.0", "4.5.2", true }, + { "4.5.21.0.0", "4.5.2", false }, + { "1.0.0", "99.0.0", true }, + { "", "99.0.0", true }, + { "4.5.2", "4.5.2", true }, + { "4.5.2", "4.5.2", true }, + { "4.5.22.0.0", "4.5.2", false }, + { "4.5.2", "4.5.2", true }, + { "2.0.0", "99.0.0", false }, + { "", "4.5.2", false } + }; + + try (MockedStatic resources = mockStatic(ResourceUtil.class)) { + for (Object[] testCase : cases) { + String serverVersion = (String) testCase[1]; + resources.when(() -> ResourceUtil.getResourceStream(ExtensionLoader.class, "version.properties")) + .thenAnswer(invocation -> new ByteArrayInputStream(("mirth.version=" + serverVersion).getBytes(StandardCharsets.UTF_8))); + for (String root : new String[] { "pluginMetaData", "connectorMetaData" }) { + String xml = "<" + root + ">" + testCase[0] + ""; + assertEquals(xml, testCase[2], MirthLauncher.isExtensionCompatible(parse(xml), serverVersion)); + assertEquals(xml, testCase[2], ExtensionLoader.getInstance().isExtensionCompatible(serializer.deserialize(xml, MetaData.class))); + } + } + } + } + + @Test + public void launcherOnlyReadsDirectMetadataChildren() throws Exception { + assertFalse(MirthLauncher.isExtensionCompatible(parse("" + + "4.5.21.0.0" + + ""), "4.5.2")); + assertTrue(MirthLauncher.isExtensionCompatible(parse("4.5.2" + + "invalid"), "4.5.2")); + } + + @Test + public void unavailableReleaseMetadataRetainsLoaderFailureBehavior() throws Exception { + try (MockedStatic resources = mockStatic(ResourceUtil.class)) { + resources.when(() -> ResourceUtil.getResourceStream(ExtensionLoader.class, "version.properties")) + .thenThrow(new FileNotFoundException("Simulated unavailable release metadata")); + PluginMetaData metadata = new PluginMetaData(); + metadata.setMirthVersion("4.5.2"); + assertFalse(ExtensionLoader.getInstance().isExtensionCompatible(metadata)); + metadata.setMinExtensionApiVersion("1.0.0"); + assertFalse(ExtensionLoader.getInstance().isExtensionCompatible(metadata)); + } + } + + private Element parse(String xml) throws Exception { + return DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(new InputSource(new StringReader(xml))).getDocumentElement(); + } +} From 763c857cc9db6ec1bf597d970e38722a9a4ccdd8 Mon Sep 17 00:00:00 2001 From: gibson9583 Date: Mon, 21 Sep 2026 14:06:50 -0400 Subject: [PATCH 2/4] docs: guide new and migrating extension authors Document new plugin and connector metadata, build dependencies, packaging, installation, restarts, and functional validation. Show migration from release lists, explain separate legacy archives, and link the author guide from the README. Validation: both complete XML templates deserialize and roundtrip with OIE's serializer; local documentation links resolve. Signed-off-by: gibson9583 --- README.md | 3 + docs/extension-compatibility.md | 231 ++++++++++++++++++++++++++------ 2 files changed, 194 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index e70808b567..58be9b448a 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,9 @@ The build is driven by Gradle through the committed wrapper; the only prerequisi On Windows use `gradlew.bat`. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full command reference, the dependency policy, and how to run the server for development. +For new plugins and connectors, or migrating existing extensions away from +release-specific compatibility, see [Writing and migrating extensions](docs/extension-compatibility.md). + --- ## Project Values diff --git a/docs/extension-compatibility.md b/docs/extension-compatibility.md index d0d91a0a5e..48afe8f53e 100644 --- a/docs/extension-compatibility.md +++ b/docs/extension-compatibility.md @@ -1,55 +1,206 @@ -# Extension compatibility +# Writing and migrating extensions -Custom plugins and connectors can opt into an OIE extension API version instead -of listing every compatible engine release. OIE currently provides extension -API **1.0.0**, versioned independently of the product release. +OIE extensions can declare a minimum extension API version instead of listing +every compatible engine release. The current extension API is **1.0.0**. It is +versioned independently of both OIE releases and your extension's own releases. -## Updating an extension +This guide covers new plugins and connectors, migrating existing extensions, +and validating an archive before publishing it. Use an OIE build containing +this feature; a product version number alone does not identify support. The +current API version is defined in +[ExtensionCompatibility.API_VERSION](../server/src/main/java/com/mirth/connect/client/core/ExtensionCompatibility.java). -Build and test the extension against OIE. Add this element directly under the -root of each applicable `plugin.xml`, `source.xml`, and `destination.xml`: +## Creating a new extension + +1. Build against the oldest OIE API baseline you intend to support, using its + existing extension interfaces and JARs. This feature introduces no new SDK, JAR layout, + Java packages, or connector base classes. Keep OIE-provided classes out of + your extension JARs. The current engine build uses JDK 17. +2. Choose your extension's own `pluginVersion`, such as `1.0.0`. For plugins + **and connectors**, this is your release version; it need not match OIE. +3. Set `minExtensionApiVersion` to the lowest OIE extension API that provides + every API your extension uses. For the initial contract, use `1.0.0`. Do not + substitute the engine release number or a build-time engine-version token. +4. Declare the appropriate metadata and libraries as shown below, then follow + [Packaging and validation](#packaging-and-validation). + +Use compile-only/provided dependencies for OIE libraries. Depending on the +extension, these include `server-lib/mirth-client-core.jar` for shared models, +`server-lib/mirth-server.jar` for server hooks, `client-lib/mirth-client.jar` +for Administrator hooks, and the required Donkey or third-party libraries from +that OIE distribution. Declare and package any additional libraries your +extension needs using the existing library mechanism. + +### Plugin metadata + +A server-only plugin can use this `plugin.xml` template. Replace the example +identity, class name, and JAR name with your implementation. The class must +implement the relevant OIE plugin interface; this metadata does not generate +that implementation. + +```xml + + Example Plugin + Example Author + 1.0.0 + 1.0.0 + An example server plugin. + + com.example.oie.ExamplePlugin + + + +``` + +For an Administrator component, also declare its `clientClasses` and `CLIENT` +libraries. Declare models needed by both sides in `SHARED` libraries. Preserve +other metadata required by your extension, such as API providers or migrations. +See the existing [Data Pruner descriptor](../server/src/main/resources/com/mirth/connect/plugins/datapruner/plugin.xml) +for the broader metadata structure; bundled descriptors still use release-based +compatibility, so use the API requirement shown above for an opted-in extension. + +### Connector metadata + +Connectors use `source.xml` and/or `destination.xml`, each with a +`connectorMetaData` root. A source descriptor template is: ```xml + + Example Listener + Example Author + 1.0.0 + 1.0.0 + An example source connector. + com.example.oie.ExampleListener + com.example.oie.ExampleReceiver + com.example.oie.ExampleReceiverProperties + + + + + example + SOURCE + +``` + +For a destination, use `destination.xml`, set `type` to `DESTINATION`, and supply +its own name and sender, dispatcher, and properties classes. See the existing +[TCP source](../server/src/main/resources/com/mirth/connect/connectors/tcp/source.xml) +and [TCP destination](../server/src/main/resources/com/mirth/connect/connectors/tcp/destination.xml) +for their respective structures. If the connector also supplies a `plugin.xml`, +that descriptor needs its own compatibility declaration too. + +## Migrating an existing extension + +1. Build and test the extension against an OIE build containing this feature. + Review its API and dependency usage. Extensions tied to unstable engine + internals should retain exact release declarations until that coupling is + addressed; changing metadata does not fix incompatible bytecode or behavior. +2. Update **every** applicable `plugin.xml`, `source.xml`, and `destination.xml` + in the archive. Replace the engine-release compatibility declaration with a + minimum extension API requirement, leaving your extension's other metadata + intact. +3. Remove build filtering that replaces the API requirement with the current + engine release. Keep `pluginVersion` under your own release policy. Existing + dependencies, package names, and implementations require no change solely + to opt into this mechanism. +4. Repackage the archive and complete [Packaging and validation](#packaging-and-validation). + Subsequent compatible OIE releases do not require another descriptor update. + +For example, change these fields: + +```xml +2.3.0 +4.5.2,4.6.0 +``` + +To: + +```xml +2.3.0 1.0.0 ``` -Keep the extension's own `pluginVersion`. Existing dependencies, Java packages, -and plugin/connector implementations do not need to change for this mechanism. -The same archive can then install and load on subsequent OIE releases providing -a compatible extension API. Continue testing against supported engine releases; -metadata declares compatibility, it does not verify binary or runtime behavior. - -When this element declares a non-null value, it replaces the `mirthVersion` -check. The engine's API version must be at least the requested version and have the same major -number: API 1.2.0 accepts a minimum of 1.0.0 or 1.2.0, but rejects 1.3.0 and -2.0.0. Versions must contain three nonnegative decimal integers separated by -periods, with no leading zeroes, suffixes, or wildcards; each component must fit -a Java signed integer. Surrounding whitespace is allowed. Empty or malformed -requirements reject the extension, even if `mirthVersion` matches. - -Omit the element (or use XStream's explicit `class="null"` representation) to -retain the existing `mirthVersion` behavior: a comma-separated list of exact -engine releases, ignoring the engine's fourth build component. -Bundled extensions continue using this existing mechanism. Older engines that -predate this feature cannot read the new metadata field; retaining `mirthVersion` -alongside it does not provide backward compatibility with those engines. +You may retain `mirthVersion`, but a non-null `minExtensionApiVersion` replaces +its check completely; the two are not combined. A future API requirement will +still reject the extension even if the legacy engine release matches. If any +other descriptor in the ZIP retains only a legacy requirement, it must still +match the engine release or installation of the entire archive is rejected. + +### Supporting older engines + +Engines predating this feature cannot read the new metadata field. Including +both fields does **not** make the same archive compatible with those engines. +If you need to support them, publish a separately identified legacy archive +that omits `minExtensionApiVersion` and retains its tested `mirthVersion` list. +Do not claim an older release supports API 1.0.0 merely because your extension +previously ran on it. To return an archive to legacy matching, remove the API +field and restore the tested release list; leaving the API field empty rejects +the extension. + +## Packaging and validation + +Keep the existing extension ZIP layout: an extension directory at the archive +root, containing its metadata and libraries. For the plugin template above, +include `example/`, `example/plugin.xml`, and `example/example-server.jar`. +The metadata `path` must match that directory, and each `library` path is +relative to it. Do not wrap the extension directory in another `extensions/` +directory. Library declarations are direct children of the metadata root, +not nested under a `libraries` element. Include directory entries before their +files, as normal recursive ZIP packaging does: + +```bash +zip -r example-extension-1.0.0.zip example/ +``` + +Before publishing an archive: + +1. Inspect its final metadata after build filtering. Check all descriptors, + class names, and library paths; do not validate only the source templates. +2. Install the ZIP through the Administrator's Extensions view on a test engine. + Restart the server and Administrator to complete installation and load the + new classes. Installation and startup both check compatibility. +3. Verify the extension is enabled and loads without compatibility, class, or + dependency errors in the server logs. Exercise its actual functionality; + for connectors, test message processing and the Administrator settings UI. +4. Test each engine release you support. When testing a newer release with a + compatible API, reuse the **same archive** without changing its metadata. + For an existing extension, also test upgrading an installation containing + the previous extension version and any stored configuration it owns. + +An incompatible requirement is rejected during installation before the archive +payload is extracted. Already installed incompatible extensions are excluded +from launcher library selection and engine metadata loading. Compatibility +metadata declares a requirement; it does not prove binary or runtime behavior. + +## Compatibility rules + +The engine API must be at least the requested version and have the same major +number. As a hypothetical later version, engine API `1.2.0` accepts a minimum of `1.0.0` or `1.2.0`, +but rejects `1.3.0` and `2.0.0`. These are **API versions**, not engine releases. + +Versions must contain three nonnegative decimal integers separated by periods, +with no leading zeroes, suffixes, or wildcards; each component must fit a Java +signed integer. Surrounding whitespace is allowed. Empty or malformed values +reject the extension, even if `mirthVersion` matches. + +Omitting the API requirement, or using XStream's explicit `class="null"` +representation, retains legacy matching: `mirthVersion` lists exact releases +separated by commas, ignoring the engine's fourth build component. Bundled +extensions continue using that existing mechanism. ## Maintaining the API version -`ExtensionCompatibility.API_VERSION` is a single compatibility contract covering +`ExtensionCompatibility.API_VERSION` is one compatibility contract covering OIE's existing extension-facing classes across the server, shared models, and -Administrator client. It is deliberately separate from the product version and -does not require new JARs or relocated classes. +Administrator client. Engine maintainers should: - Leave it unchanged for product releases that preserve the extension contract. -- Increase the minor version for compatible API additions; extensions using - those additions must declare the new minimum. -- Increase the patch version for compatible contract fixes when extensions need - to require that fix. +- Increase the minor version for compatible API additions. Extension authors + using those additions must raise their minimum; existing extensions retain + their earlier minimum. +- Increase the patch version for compatible contract fixes when extensions + need to require that fix. - Increase the major version for incompatible changes, including incompatible - changes to dependencies exposed through the extension API. Extensions must be - reviewed and updated before declaring support for that major version. - -Extensions depending on implementation details outside that contract should -continue declaring exact engine releases. Compatibility checks run during -installation, launcher classpath construction, and engine metadata loading. + changes to dependencies exposed through the extension API. Extension authors + must review, update, and test their code before declaring that major version. From e95dfde6b51e833f95bb2b56b6bfe7aaefbe7c38 Mon Sep 17 00:00:00 2001 From: gibson9583 Date: Mon, 21 Sep 2026 15:30:05 -0400 Subject: [PATCH 3/4] feat: validate engine and plugin extension dependencies Allow extensions to declare required plugins and the engine API in one metadata list. Check the planned installation inventory and reject provider changes that would break enabled consumers. Keep legacy declarations and plugin initialization behavior intact. Document new extensions, migration, and dependency validation. Validation: 748 tests; 15 isolated launcher scenarios. Signed-off-by: gibson9583 --- docs/extension-compatibility.md | 150 +++-- server/build.gradle | 2 + .../client/core/ExtensionCompatibility.java | 4 + .../client/core/ExtensionDependencies.java | 203 +++++++ .../client/core/ExtensionDependency.java | 49 ++ .../com/mirth/connect/model/MetaData.java | 10 + .../ExtensionDependenciesConverter.java | 70 +++ .../model/converters/ObjectXMLSerializer.java | 1 + .../mirth/connect/server/ExtensionLoader.java | 145 +++-- .../DefaultExtensionController.java | 298 ++++++++-- .../server/launcher/MirthLauncher.java | 205 +++++-- .../core/ExtensionDependenciesTest.java | 235 ++++++++ .../connect/model/ExtensionMetaDataTest.java | 38 ++ .../ExtensionDependencyInstallationTest.java | 538 ++++++++++++++++++ .../launcher/ExtensionDependenciesTest.java | 297 ++++++++++ 15 files changed, 2049 insertions(+), 196 deletions(-) create mode 100644 server/src/main/java/com/mirth/connect/client/core/ExtensionDependencies.java create mode 100644 server/src/main/java/com/mirth/connect/client/core/ExtensionDependency.java create mode 100644 server/src/main/java/com/mirth/connect/model/converters/ExtensionDependenciesConverter.java create mode 100644 server/src/test/java/com/mirth/connect/client/core/ExtensionDependenciesTest.java create mode 100644 server/src/test/java/com/mirth/connect/server/controllers/ExtensionDependencyInstallationTest.java create mode 100644 server/src/test/java/com/mirth/connect/server/launcher/ExtensionDependenciesTest.java diff --git a/docs/extension-compatibility.md b/docs/extension-compatibility.md index 48afe8f53e..b22553b17c 100644 --- a/docs/extension-compatibility.md +++ b/docs/extension-compatibility.md @@ -1,8 +1,9 @@ # Writing and migrating extensions -OIE extensions can declare a minimum extension API version instead of listing -every compatible engine release. The current extension API is **1.0.0**. It is -versioned independently of both OIE releases and your extension's own releases. +OIE extensions can declare dependencies on the engine API and on other plugins. +The current extension API is **1.0.0**, versioned independently of OIE releases +and extension releases. Declaring an API requirement avoids listing every +compatible engine release. This guide covers new plugins and connectors, migrating existing extensions, and validating an archive before publishing it. Use an OIE build containing @@ -18,9 +19,10 @@ current API version is defined in your extension JARs. The current engine build uses JDK 17. 2. Choose your extension's own `pluginVersion`, such as `1.0.0`. For plugins **and connectors**, this is your release version; it need not match OIE. -3. Set `minExtensionApiVersion` to the lowest OIE extension API that provides - every API your extension uses. For the initial contract, use `1.0.0`. Do not - substitute the engine release number or a build-time engine-version token. +3. Add an `engine-api` dependency with `minVersion` set to the lowest OIE + extension API that provides every API your extension uses. For the initial + contract, use `1.0.0`. Do not substitute the engine release number or a + build-time engine-version token. 4. Declare the appropriate metadata and libraries as shown below, then follow [Packaging and validation](#packaging-and-validation). @@ -43,7 +45,9 @@ that implementation. Example Plugin Example Author 1.0.0 - 1.0.0 + + + An example server plugin. com.example.oie.ExamplePlugin @@ -69,7 +73,9 @@ Connectors use `source.xml` and/or `destination.xml`, each with a Example Listener Example Author 1.0.0 - 1.0.0 + + + An example source connector. com.example.oie.ExampleListener com.example.oie.ExampleReceiver @@ -90,6 +96,37 @@ and [TCP destination](../server/src/main/resources/com/mirth/connect/connectors/ for their respective structures. If the connector also supplies a `plugin.xml`, that descriptor needs its own compatibility declaration too. +### Requiring another plugin + +Add a `plugin` dependency alongside the engine requirement: + +```xml + + + + +``` + +The `name` must exactly match the provider's metadata `name`, including case; +it is not the directory `path`, class name, or JAR name. The version is checked +against that provider's **`pluginVersion`**, independently of the engine API. +Both plugins and connectors can require plugins; connectors cannot be named +as dependency providers. Keep a provider's name stable between releases. + +All dependencies are required. A provider must be installed, enabled, satisfy +the version requirement, and satisfy its own dependencies. Install providers +first, or package the consumer and its providers together in one ZIP. OIE +does not download dependencies. Optional dependencies and version ranges are +not supported. + +Dependencies control availability and compatibility. They do not change +class initialization or start order: existing weights and conditions still +apply. They do not guarantee that a provider's service has initialized or +started successfully. Code that uses another plugin's services must handle +its lifecycle and runtime failures. Declare dependencies on every descriptor +that needs the provider, including server, Administrator, shared-library, and +controller hooks. + ## Migrating an existing extension 1. Build and test the extension against an OIE build containing this feature. @@ -118,25 +155,38 @@ To: ```xml 2.3.0 -1.0.0 + + + ``` -You may retain `mirthVersion`, but a non-null `minExtensionApiVersion` replaces -its check completely; the two are not combined. A future API requirement will -still reject the extension even if the legacy engine release matches. If any -other descriptor in the ZIP retains only a legacy requirement, it must still -match the engine release or installation of the entire archive is rejected. +Add `plugin` entries for any required plugins and test against the oldest +provider versions you declare. Merely adding metadata does not remove a +runtime dependency or make incompatible provider releases usable. + +You may retain `mirthVersion`, but an `engine-api` dependency replaces its +check completely. A future API requirement still rejects the extension even +if the legacy engine release matches. Every descriptor in the ZIP must pass +its own checks or the entire archive is rejected. + +If you already use `1.0.0`, it +remains supported. To migrate, remove that element and add the corresponding +`engine-api` dependency. Declaring both is invalid. Plugin dependencies can +also be added while retaining either the shorthand API requirement or legacy +release matching. ### Supporting older engines -Engines predating this feature cannot read the new metadata field. Including -both fields does **not** make the same archive compatible with those engines. +Engines predating dependency-list support cannot read `dependencies`. +Including legacy fields does **not** make the same archive compatible with +those engines. If you need to support them, publish a separately identified legacy archive -that omits `minExtensionApiVersion` and retains its tested `mirthVersion` list. +that omits `dependencies` and `minExtensionApiVersion` and retains its tested +`mirthVersion` list. Document and verify its plugin prerequisites separately. Do not claim an older release supports API 1.0.0 merely because your extension previously ran on it. To return an archive to legacy matching, remove the API -field and restore the tested release list; leaving the API field empty rejects -the extension. +declaration and restore the tested release list; leaving an API requirement +empty rejects the extension. ## Packaging and validation @@ -144,7 +194,9 @@ Keep the existing extension ZIP layout: an extension directory at the archive root, containing its metadata and libraries. For the plugin template above, include `example/`, `example/plugin.xml`, and `example/example-server.jar`. The metadata `path` must match that directory, and each `library` path is -relative to it. Do not wrap the extension directory in another `extensions/` +relative to it. Keep package-directory spelling and case unchanged when +updating or uninstalling an extension. Archive paths that differ only by case +are rejected to avoid filesystem-dependent results. Do not wrap the extension directory in another `extensions/` directory. Library declarations are direct children of the metadata root, not nested under a `libraries` element. Include directory entries before their files, as normal recursive ZIP packaging does: @@ -167,27 +219,61 @@ Before publishing an archive: compatible API, reuse the **same archive** without changing its metadata. For an existing extension, also test upgrading an installation containing the previous extension version and any stored configuration it owns. +5. For plugin dependencies, test a missing or disabled provider, a provider + below the minimum, and a provider with a different major version. Check + transitive failures, dependency cycles, and recovery after correcting the + declarations or enabling compatible providers. Verify a provider update, + disable, or removal cannot break an enabled consumer with satisfied + dependencies. An incompatible requirement is rejected during installation before the archive -payload is extracted. Already installed incompatible extensions are excluded -from launcher library selection and engine metadata loading. Compatibility -metadata declares a requirement; it does not prove binary or runtime behavior. +payload is extracted. Installation checks the complete inventory for the next +startup: installed packages, pending removals, staged installations, and every +descriptor in the new ZIP. Descriptor order within the ZIP does not determine +dependency availability. Pending installs replace the corresponding package +directory, including its descriptors. + +A provider update, disable, or removal is rejected if it would break the +dependencies of an enabled consumer whose requirements currently pass. Disable +or uninstall consumers first when intentionally removing their providers. +Restart the server and Administrator to apply staged changes; staging does +not unload classes from a running process. + +At startup, dependency checks run before extension libraries are admitted and +before metadata is made available to the engine. Unsatisfied extensions are +excluded, with diagnostics identifying the requirement. Disabled consumers +still need valid declarations and engine compatibility, but their plugin +requirements are checked when they are enabled. Compatibility metadata +declares a requirement; it does not prove binary or runtime behavior. ## Compatibility rules -The engine API must be at least the requested version and have the same major -number. As a hypothetical later version, engine API `1.2.0` accepts a minimum of `1.0.0` or `1.2.0`, -but rejects `1.3.0` and `2.0.0`. These are **API versions**, not engine releases. +For either dependency type, the available version must be at least `minVersion` +and have the same major number. For example, engine API `1.2.0` accepts a +minimum of `1.0.0` or `1.2.0`, but rejects `1.3.0` and `2.0.0`. A plugin +requirement of `2.1.0` accepts provider release `2.2.0`, but rejects `2.0.0` +and `3.0.0`. Plugin authors should use major version changes to signal +incompatible changes to their published extension interfaces. Versions must contain three nonnegative decimal integers separated by periods, with no leading zeroes, suffixes, or wildcards; each component must fit a Java signed integer. Surrounding whitespace is allowed. Empty or malformed values -reject the extension, even if `mirthVersion` matches. - -Omitting the API requirement, or using XStream's explicit `class="null"` -representation, retains legacy matching: `mirthVersion` lists exact releases -separated by commas, ignoring the engine's fourth build component. Bundled -extensions continue using that existing mechanism. +reject the requirement, even if `mirthVersion` matches. A required provider's +`pluginVersion` must use this format too; the engine does not infer compatibility +from arbitrary release labels. + +Only `engine-api` and `plugin` dependency types are supported, and both require +`minVersion`. Plugin dependencies also require `name`; engine dependencies do +not use a name. Unknown types, malformed declarations, duplicate requirements, +self-dependencies, cycles, and ambiguous duplicate provider names are rejected. + +The engine check uses the `engine-api` dependency if present. Otherwise it uses +a non-null `minExtensionApiVersion`, then legacy `mirthVersion` matching when +the shorthand is absent or explicitly null. A list containing only plugin +dependencies, or an empty list, never skips the engine check. Legacy matching +accepts exact comma-separated engine releases, ignoring the engine's fourth +build component. Existing descriptors without the new declarations keep this +behavior. ## Maintaining the API version diff --git a/server/build.gradle b/server/build.gradle index a33a94a5f1..fc5258873c 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -680,6 +680,8 @@ def launcherJar = tasks.register('launcherJar', Jar) { from(sourceSets.main.output.classesDirs) { // The launcher checks compatibility before loading mirth-client-core.jar. include 'com/mirth/connect/client/core/ExtensionCompatibility.class' + include 'com/mirth/connect/client/core/ExtensionDependency.class' + include 'com/mirth/connect/client/core/ExtensionDependencies*.class' include 'com/mirth/connect/server/launcher/**' include 'com/mirth/connect/server/extprops/**' } diff --git a/server/src/main/java/com/mirth/connect/client/core/ExtensionCompatibility.java b/server/src/main/java/com/mirth/connect/client/core/ExtensionCompatibility.java index e9b77857b8..7a06043ad1 100644 --- a/server/src/main/java/com/mirth/connect/client/core/ExtensionCompatibility.java +++ b/server/src/main/java/com/mirth/connect/client/core/ExtensionCompatibility.java @@ -48,6 +48,10 @@ static boolean isApiCompatible(String minimumVersion, String currentVersion) { || (current[1] == minimum[1] && current[2] >= minimum[2])); } + static boolean isValidVersion(String version) { + return parseApiVersion(version) != null; + } + private static int[] parseApiVersion(String version) { if (version == null) { return null; diff --git a/server/src/main/java/com/mirth/connect/client/core/ExtensionDependencies.java b/server/src/main/java/com/mirth/connect/client/core/ExtensionDependencies.java new file mode 100644 index 0000000000..728e18ad03 --- /dev/null +++ b/server/src/main/java/com/mirth/connect/client/core/ExtensionDependencies.java @@ -0,0 +1,203 @@ +/* + * Copyright (c) Open Integration Engine contributors. + * Licensed under the Mozilla Public License 2.0. + */ + +package com.mirth.connect.client.core; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; + +/** Shared dependency validation; must remain usable before engine classes are loaded. */ +public final class ExtensionDependencies { + private ExtensionDependencies() {} + + /** One descriptor in the proposed extension inventory. Identity is per descriptor. */ + public static final class Extension { + private final String name; + private final boolean plugin; + private final String pluginVersion; + private final String mirthVersion; + private final String minExtensionApiVersion; + private final List dependencies; + private final boolean enabled; + + public Extension(String name, boolean plugin, String pluginVersion, String mirthVersion, + String minExtensionApiVersion, List dependencies, boolean enabled) { + this.name = name; + this.plugin = plugin; + this.pluginVersion = pluginVersion; + this.mirthVersion = mirthVersion; + this.minExtensionApiVersion = minExtensionApiVersion; + this.dependencies = dependencies == null ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(dependencies)); + this.enabled = enabled; + } + + public String getName() { + return name; + } + + public boolean isPlugin() { + return plugin; + } + + public boolean isEnabled() { + return enabled; + } + } + + /** Returns the first declaration or engine-compatibility error, or null on success. */ + public static String getEngineError(Extension extension, String serverVersion) { + String minimumApiVersion = extension.minExtensionApiVersion; + boolean engineRequirement = false; + Set pluginRequirements = new HashSet<>(); + + for (ExtensionDependency dependency : extension.dependencies) { + if (dependency == null) { + return "Dependency declaration must not be null."; + } + if (!ExtensionCompatibility.isValidVersion(dependency.getMinVersion())) { + return "Dependency minVersion must be a numeric major.minor.patch version."; + } + if ("engine-api".equals(dependency.getType())) { + if (engineRequirement || extension.minExtensionApiVersion != null) { + return "Declare the engine API requirement only once."; + } + if (dependency.getName() != null) { + return "An engine-api dependency must not declare a name."; + } + engineRequirement = true; + minimumApiVersion = dependency.getMinVersion(); + } else if ("plugin".equals(dependency.getType())) { + String name = dependency.getName(); + if (name == null || name.trim().isEmpty()) { + return "A plugin dependency must declare its exact metadata name."; + } + if (!pluginRequirements.add(name)) { + return "Plugin dependency '" + name + "' is declared more than once."; + } + if (extension.plugin && name.equals(extension.name)) { + return "A plugin cannot depend on itself: '" + name + "'."; + } + } else { + return "Unknown dependency type '" + dependency.getType() + "'."; + } + } + + if (!ExtensionCompatibility.isCompatible(extension.mirthVersion, minimumApiVersion, serverVersion)) { + return minimumApiVersion == null + ? "The engine release does not match mirthVersion '" + extension.mirthVersion + "'." + : "Requires engine API '" + minimumApiVersion + "' with the same major version; current API is '" + + ExtensionCompatibility.API_VERSION + "'."; + } + return null; + } + + /** + * Checks the complete inventory. Disabled descriptors retain declaration/engine checks, but + * only enabled consumers require their plugins to be available. Results follow inventory order. + */ + public static Map validate(Collection extensions, String serverVersion) { + List inventory = new ArrayList<>(extensions); + Map errors = new HashMap<>(); + Map plugins = new HashMap<>(); + Set duplicateNames = new HashSet<>(); + + for (Extension extension : inventory) { + String error = getEngineError(extension, serverVersion); + if (error != null) { + errors.put(extension, error); + } + if (extension.plugin && plugins.putIfAbsent(extension.name, extension) != null) { + duplicateNames.add(extension.name); + } + } + for (Extension extension : inventory) { + if (extension.plugin && duplicateNames.contains(extension.name)) { + errors.put(extension, "More than one plugin declares the name '" + extension.name + "'."); + } + } + + Map> consumers = new HashMap<>(); + Map remaining = new HashMap<>(); + for (Extension extension : inventory) { + remaining.put(extension, 0); + if (!extension.enabled || errors.containsKey(extension)) { + continue; + } + for (ExtensionDependency dependency : extension.dependencies) { + if (!"plugin".equals(dependency.getType())) { + continue; + } + String name = dependency.getName(); + Extension provider = plugins.get(name); + String error = null; + if (provider == null) { + error = "Required plugin '" + name + "' is not installed."; + } else if (duplicateNames.contains(name)) { + error = "Required plugin name '" + name + "' is ambiguous."; + } else if (!provider.enabled) { + error = "Required plugin '" + name + "' is disabled."; + } else if (!ExtensionCompatibility.isApiCompatible(dependency.getMinVersion(), provider.pluginVersion)) { + error = "Required plugin '" + name + "' needs version '" + dependency.getMinVersion() + + "' or later with the same major version; installed version is '" + provider.pluginVersion + "'."; + } + if (error != null) { + errors.put(extension, error); + break; + } + consumers.computeIfAbsent(provider, key -> new ArrayList<>()).add(extension); + remaining.put(extension, remaining.get(extension) + 1); + } + } + + // Resolve providers before consumers. Failed providers propagate immediately, including + // into cycles. Any nodes left afterward belong to, or depend on, a dependency cycle. + Queue ready = new ArrayDeque<>(); + Set resolved = new HashSet<>(); + for (Extension extension : inventory) { + if (errors.containsKey(extension) || remaining.get(extension) == 0) { + ready.add(extension); + } + } + while (!ready.isEmpty()) { + Extension provider = ready.remove(); + if (!resolved.add(provider)) { + continue; + } + for (Extension consumer : consumers.getOrDefault(provider, Collections.emptyList())) { + if (errors.containsKey(provider) && !errors.containsKey(consumer)) { + errors.put(consumer, "Required plugin '" + provider.name + + "' cannot be loaded; see its compatibility or dependency error."); + ready.add(consumer); + } + int count = remaining.get(consumer) - 1; + remaining.put(consumer, count); + if (count == 0) { + ready.add(consumer); + } + } + } + + Map orderedErrors = new LinkedHashMap<>(); + for (Extension extension : inventory) { + if (!resolved.contains(extension)) { + errors.put(extension, "Circular plugin dependencies prevent loading '" + extension.name + "'."); + } + if (errors.containsKey(extension)) { + orderedErrors.put(extension, errors.get(extension)); + } + } + return orderedErrors; + } +} diff --git a/server/src/main/java/com/mirth/connect/client/core/ExtensionDependency.java b/server/src/main/java/com/mirth/connect/client/core/ExtensionDependency.java new file mode 100644 index 0000000000..8da15789aa --- /dev/null +++ b/server/src/main/java/com/mirth/connect/client/core/ExtensionDependency.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) Open Integration Engine contributors. + * Licensed under the Mozilla Public License 2.0. + */ + +package com.mirth.connect.client.core; + +import java.io.Serializable; + +/** A required engine API or plugin version declared by an extension. */ +public class ExtensionDependency implements Serializable { + private static final long serialVersionUID = 1L; + + private String type; + private String name; + private String minVersion; + + public ExtensionDependency() {} + + public ExtensionDependency(String type, String name, String minVersion) { + this.type = type; + this.name = name; + this.minVersion = minVersion; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getMinVersion() { + return minVersion; + } + + public void setMinVersion(String minVersion) { + this.minVersion = minVersion; + } +} diff --git a/server/src/main/java/com/mirth/connect/model/MetaData.java b/server/src/main/java/com/mirth/connect/model/MetaData.java index dbaec4a6fe..bd9dc829a6 100644 --- a/server/src/main/java/com/mirth/connect/model/MetaData.java +++ b/server/src/main/java/com/mirth/connect/model/MetaData.java @@ -15,6 +15,7 @@ import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.builder.ToStringBuilder; +import com.mirth.connect.client.core.ExtensionDependency; import com.mirth.connect.client.core.Version; import com.thoughtworks.xstream.annotations.XStreamAlias; import com.thoughtworks.xstream.annotations.XStreamAsAttribute; @@ -28,6 +29,7 @@ public abstract class MetaData { private String author; private String mirthVersion; private String minExtensionApiVersion; + private List dependencies; private String pluginVersion; private String url; private String description; @@ -82,6 +84,14 @@ public void setMinExtensionApiVersion(String minExtensionApiVersion) { this.minExtensionApiVersion = minExtensionApiVersion; } + public List getDependencies() { + return dependencies; + } + + public void setDependencies(List dependencies) { + this.dependencies = dependencies; + } + public String getPluginVersion() { return pluginVersion; } diff --git a/server/src/main/java/com/mirth/connect/model/converters/ExtensionDependenciesConverter.java b/server/src/main/java/com/mirth/connect/model/converters/ExtensionDependenciesConverter.java new file mode 100644 index 0000000000..3e55f27970 --- /dev/null +++ b/server/src/main/java/com/mirth/connect/model/converters/ExtensionDependenciesConverter.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) Open Integration Engine contributors. + * Licensed under the Mozilla Public License 2.0. + */ + +package com.mirth.connect.model.converters; + +import java.util.ArrayList; +import java.util.List; + +import com.mirth.connect.client.core.ExtensionDependency; +import com.thoughtworks.xstream.converters.ConversionException; +import com.thoughtworks.xstream.converters.Converter; +import com.thoughtworks.xstream.converters.MarshallingContext; +import com.thoughtworks.xstream.converters.UnmarshallingContext; +import com.thoughtworks.xstream.io.HierarchicalStreamReader; +import com.thoughtworks.xstream.io.HierarchicalStreamWriter; + +/** Keeps dependency declarations unambiguous for both XStream and the bootstrap DOM reader. */ +public class ExtensionDependenciesConverter implements Converter { + @Override + public boolean canConvert(Class type) { + return type == null || List.class.isAssignableFrom(type); + } + + @Override + public void marshal(Object value, HierarchicalStreamWriter writer, MarshallingContext context) { + for (ExtensionDependency dependency : (List) value) { + writer.startNode("dependency"); + if (dependency != null) { + writeAttribute(writer, "type", dependency.getType()); + writeAttribute(writer, "name", dependency.getName()); + writeAttribute(writer, "minVersion", dependency.getMinVersion()); + } + writer.endNode(); + } + } + + private void writeAttribute(HierarchicalStreamWriter writer, String name, String value) { + if (value != null) { + writer.addAttribute(name, value); + } + } + + @Override + public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext context) { + if (context.getRequiredType() == null) { + return null; + } + List dependencies = new ArrayList<>(); + if (!reader.getValue().trim().isEmpty()) { + throw new ConversionException("Dependencies must contain dependency elements."); + } + while (reader.hasMoreChildren()) { + reader.moveDown(); + if (!"dependency".equals(reader.getNodeName()) || reader.hasMoreChildren() || !reader.getValue().trim().isEmpty()) { + throw new ConversionException("Expected a dependency with type, name and minVersion attributes."); + } + for (int i = 0; i < reader.getAttributeCount(); i++) { + String name = reader.getAttributeName(i); + if (!"type".equals(name) && !"name".equals(name) && !"minVersion".equals(name)) { + throw new ConversionException("Unknown dependency attribute: " + name); + } + } + dependencies.add(new ExtensionDependency(reader.getAttribute("type"), reader.getAttribute("name"), reader.getAttribute("minVersion"))); + reader.moveUp(); + } + return dependencies; + } +} diff --git a/server/src/main/java/com/mirth/connect/model/converters/ObjectXMLSerializer.java b/server/src/main/java/com/mirth/connect/model/converters/ObjectXMLSerializer.java index d549f89cc3..e80b08efa7 100644 --- a/server/src/main/java/com/mirth/connect/model/converters/ObjectXMLSerializer.java +++ b/server/src/main/java/com/mirth/connect/model/converters/ObjectXMLSerializer.java @@ -167,6 +167,7 @@ public ObjectXMLSerializer(int xstreamMode, ClassLoader classLoader) { processAnnotations(annotatedClasses); getXStream().registerConverter(new MapContentConverter(getXStream().getMapper())); getXStream().registerConverter(new PluginMetaDataConverter(getXStream().getMapper())); + getXStream().registerLocalConverter(MetaData.class, "dependencies", new ExtensionDependenciesConverter()); getXStream().registerConverter(new JavaScriptObjectConverter(getXStream().getMapper())); getXStream().registerConverter(new ThrowableConverter(getXStream().getMapper())); getXStream().registerConverter(new FilterTransformerElementsConverter(getXStream().getMapper())); diff --git a/server/src/main/java/com/mirth/connect/server/ExtensionLoader.java b/server/src/main/java/com/mirth/connect/server/ExtensionLoader.java index e9c3869057..2d75db1784 100644 --- a/server/src/main/java/com/mirth/connect/server/ExtensionLoader.java +++ b/server/src/main/java/com/mirth/connect/server/ExtensionLoader.java @@ -12,25 +12,27 @@ import java.io.File; import java.io.FileNotFoundException; import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.TreeMap; +import java.util.function.Predicate; -import com.mirth.connect.client.core.BrandingConstants; import org.apache.commons.configuration2.PropertiesConfiguration; import org.apache.commons.configuration2.ex.ConfigurationException; import org.apache.commons.io.FileUtils; -import org.apache.commons.io.filefilter.AndFileFilter; -import org.apache.commons.io.filefilter.FileFilterUtils; -import org.apache.commons.io.filefilter.IOFileFilter; -import org.apache.commons.io.filefilter.NameFileFilter; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import com.google.inject.Inject; -import com.mirth.connect.client.core.ExtensionCompatibility; +import com.mirth.connect.client.core.ControllerException; +import com.mirth.connect.client.core.ExtensionDependencies; +import com.mirth.connect.client.core.ExtensionDependencies.Extension; import com.mirth.connect.client.core.PropertiesConfigurationUtil; import com.mirth.connect.model.ConnectorMetaData; import com.mirth.connect.model.MetaData; @@ -140,66 +142,105 @@ public T getControllerInstance(Class abstractClass) { return null; } + /** Checks declarations and the engine requirement; plugin requirements need the full inventory. */ public boolean isExtensionCompatible(MetaData metaData) { - String serverMirthVersion; try { - serverMirthVersion = getServerVersion(); + return ExtensionDependencies.getEngineError(describe(metaData, true), getServerVersion()) == null; } catch (Exception e) { - logger.error("An error occurred while attempting to determine the current server version.", e); + logger.error("An error occurred while attempting to determine extension compatibility.", e); return false; } + } - logger.debug("Checking extension \"{}\" compatibility: releases={}, minimum API={}, server={}, API={}", - metaData.getName(), metaData.getMirthVersion(), metaData.getMinExtensionApiVersion(), - serverMirthVersion, ExtensionCompatibility.API_VERSION); - return ExtensionCompatibility.isCompatible(metaData.getMirthVersion(), - metaData.getMinExtensionApiVersion(), serverMirthVersion); + /** Validates a complete inventory, including providers rejected by their own requirements. */ + public Map getCompatibilityErrors(Collection metadata, Predicate enabled) throws ControllerException { + Map descriptors = new LinkedHashMap<>(); + Map errors = new LinkedHashMap<>(); + for (MetaData extension : metadata) { + try { + descriptors.put(describe(extension, enabled.test(extension.getName())), extension); + } catch (Exception e) { + errors.put(extension, "Could not read extension status or metadata: " + e.getMessage()); + } + } + try { + for (Map.Entry error : ExtensionDependencies.validate(descriptors.keySet(), getServerVersion()).entrySet()) { + errors.put(descriptors.get(error.getKey()), error.getValue()); + } + } catch (Exception e) { + logger.error("An error occurred while attempting to determine extension compatibility.", e); + throw new ControllerException("Could not determine extension compatibility.", e); + } + return errors; + } + + private Extension describe(MetaData metadata, boolean enabled) { + return new Extension(metadata.getName(), metadata instanceof PluginMetaData, metadata.getPluginVersion(), + metadata.getMirthVersion(), metadata.getMinExtensionApiVersion(), metadata.getDependencies(), enabled); + } + + /** Reads the same package/descriptor layout as the launcher, without including pending installs. */ + public Map> readExtensionMetaData(File extensionPath) { + Map> packages = new TreeMap<>(); + File[] directories = extensionPath.listFiles(File::isDirectory); + if (directories == null) { + return packages; + } + for (File directory : directories) { + if (directory.getName().equals("install_temp") || directory.getName().startsWith(".install-")) { + continue; + } + List metadata = new ArrayList<>(); + File[] files = directory.listFiles(file -> file.isFile() && isMetaDataFile(file.getName())); + if (files == null) { + continue; + } + Arrays.sort(files); + for (File file : files) { + try { + MetaData extension = serializer.deserialize(FileUtils.readFileToString(file), MetaData.class); + if (!(extension instanceof PluginMetaData || extension instanceof ConnectorMetaData) + || StringUtils.isBlank(extension.getName())) { + throw new IllegalArgumentException("Expected named plugin or connector metadata."); + } + metadata.add(extension); + } catch (Exception e) { + logger.error("Error reading or parsing extension metadata file: {}", file, e); + } + } + packages.put(directory.getName(), metadata); + } + return packages; + } + + public static boolean isMetaDataFile(String name) { + return "plugin.xml".equalsIgnoreCase(name) || "source.xml".equalsIgnoreCase(name) || "destination.xml".equalsIgnoreCase(name); } - /** - * Loads the metadata files (plugin.xml, source.xml, destination.xml) for all extensions of the - * specified type. If this function fails to parse the metadata file for an extension, it will - * skip it and continue. - */ private synchronized void loadExtensions() { if (!loadedExtensions) { try { - // match all of the file names for the extension - IOFileFilter nameFileFilter = new NameFileFilter(new String[] { "plugin.xml", - "source.xml", "destination.xml" }); - // this is probably not needed, but we dont want to pick up directories, - // so we AND the two filters - IOFileFilter andFileFilter = new AndFileFilter(nameFileFilter, FileFilterUtils.fileFileFilter()); - // this is directory where extensions are located - File extensionPath = new File(getExtensionsPath()); - // do a recursive scan for extension files - Collection extensionFiles = FileUtils.listFiles(extensionPath, andFileFilter, FileFilterUtils.trueFileFilter()); - - for (File extensionFile : extensionFiles) { - try { - MetaData metaData = (MetaData) serializer.deserialize(FileUtils.readFileToString(extensionFile), MetaData.class); - - if (isExtensionCompatible(metaData)) { - if (metaData instanceof ConnectorMetaData) { - ConnectorMetaData connectorMetaData = (ConnectorMetaData) metaData; - connectorMetaDataMap.put(connectorMetaData.getName(), connectorMetaData); - - if (StringUtils.contains(connectorMetaData.getProtocol(), ":")) { - for (String protocol : connectorMetaData.getProtocol().split(":")) { - connectorProtocolsMap.put(protocol, connectorMetaData); - } - } else { - connectorProtocolsMap.put(connectorMetaData.getProtocol(), connectorMetaData); - } - } else if (metaData instanceof PluginMetaData) { - pluginMetaDataMap.put(metaData.getName(), (PluginMetaData) metaData); + List metadata = new ArrayList<>(); + for (List extensions : readExtensionMetaData(new File(getExtensionsPath())).values()) { + metadata.addAll(extensions); + } + Map errors = getCompatibilityErrors(metadata, ExtensionStatuses.getInstance()::isEnabled); + for (MetaData metaData : metadata) { + if (errors.containsKey(metaData)) { + logger.error("Extension \"{}\" was not loaded: {}", metaData.getName(), errors.get(metaData)); + invalidMetaDataMap.put(metaData.getName(), metaData); + } else if (metaData instanceof ConnectorMetaData) { + ConnectorMetaData connectorMetaData = (ConnectorMetaData) metaData; + connectorMetaDataMap.put(connectorMetaData.getName(), connectorMetaData); + if (StringUtils.contains(connectorMetaData.getProtocol(), ":")) { + for (String protocol : connectorMetaData.getProtocol().split(":")) { + connectorProtocolsMap.put(protocol, connectorMetaData); } } else { - logger.error("Extension \"{}\" is not compatible with this version of {} and was not loaded. Please install a compatible version.", metaData.getName(), BrandingConstants.PRODUCT_NAME); - invalidMetaDataMap.put(metaData.getName(), metaData); + connectorProtocolsMap.put(connectorMetaData.getProtocol(), connectorMetaData); } - } catch (Exception e) { - logger.error("Error reading or parsing extension metadata file: {}", extensionFile.getName(), e); + } else if (metaData instanceof PluginMetaData) { + pluginMetaDataMap.put(metaData.getName(), (PluginMetaData) metaData); } } } catch (Exception e) { diff --git a/server/src/main/java/com/mirth/connect/server/controllers/DefaultExtensionController.java b/server/src/main/java/com/mirth/connect/server/controllers/DefaultExtensionController.java index 77b02c1874..7a784b66a7 100644 --- a/server/src/main/java/com/mirth/connect/server/controllers/DefaultExtensionController.java +++ b/server/src/main/java/com/mirth/connect/server/controllers/DefaultExtensionController.java @@ -17,14 +17,20 @@ import java.io.InputStream; import java.io.OutputStream; import java.io.StringReader; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.Enumeration; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.NavigableMap; import java.util.Properties; @@ -37,7 +43,6 @@ import javax.xml.parsers.DocumentBuilderFactory; -import com.mirth.connect.client.core.BrandingConstants; import org.apache.commons.io.FileUtils; import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.IOUtils; @@ -362,9 +367,28 @@ public MultiFactorAuthenticationPlugin getMultiFactorAuthenticationPlugin() { /* ********************************************************************** */ @Override - public void setExtensionEnabled(String extensionName, boolean enabled) throws ControllerException { + public synchronized void setExtensionEnabled(String extensionName, boolean enabled) throws ControllerException { + Map> inventory = getPlannedExtensions(); + List required = new ArrayList<>(); + if (enabled) { + for (MetaData metadata : flatten(inventory)) { + if (extensionName.equals(metadata.getName())) { + required.add(metadata); + } + } + } + String error = getExtensionChangeError(inventory, inventory, required, extensionName, enabled); + if (error != null) { + throw new ControllerException(error); + } + boolean previous = extensionStatuses.isEnabled(extensionName); extensionStatuses.setEnabled(extensionName, enabled); - extensionStatuses.save(); + try { + extensionStatuses.save(); + } catch (RuntimeException e) { + extensionStatuses.setEnabled(extensionName, previous); + throw new ControllerException("Could not save extension status.", e); + } } @Override @@ -429,80 +453,228 @@ public void updatePluginProperties(String name, Properties properties) { } @Override - public InstallationResult extractExtension(InputStream inputStream) { + public synchronized InstallationResult extractExtension(InputStream inputStream) { Throwable cause = null; - Set metaDataSet = new HashSet(); - + Set metaDataSet = new LinkedHashSet<>(); File installTempDir = new File(ExtensionController.getExtensionsPath(), "install_temp"); + File workDir = null; + try { + FileUtils.forceMkdir(installTempDir); + // Keep incomplete extraction outside install_temp, where it cannot become a provider. + workDir = Files.createTempDirectory(installTempDir.getParentFile().toPath(), ".install-").toFile(); + File archive = new File(workDir, "extension.zip"); + try (FileOutputStream output = new FileOutputStream(archive)) { + IOUtils.copy(inputStream, output); + } + File payload = new File(workDir, "payload"); + FileUtils.forceMkdir(payload); + Map> incoming = new LinkedHashMap<>(); + try (ZipFile zipFile = new ZipFile(archive)) { + Enumeration entries = zipFile.entries(); + Set entryNames = new HashSet<>(); + Map packageNames = new HashMap<>(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + String name = entry.getName(); + String[] parts = name.split("/"); + if (!entryNames.add(name.toLowerCase(Locale.ROOT)) || parts.length == 0 || parts[0].isEmpty() + || name.contains("\\") || name.startsWith("/") + || isReservedExtensionPath(parts[0]) + || (!entry.isDirectory() && parts.length < 2)) { + throw new ZipException("Invalid extension archive entry: " + name); + } + for (String part : parts) { + if (part.equals(".") || part.equals("..") || part.isEmpty()) { + throw new ZipException("Invalid extension archive entry: " + name); + } + } + String previousName = packageNames.putIfAbsent(parts[0].toLowerCase(Locale.ROOT), parts[0]); + if (previousName != null && !previousName.equals(parts[0])) { + throw new ZipException("Package paths must not differ only by case: " + parts[0]); + } + List metadata = incoming.computeIfAbsent(parts[0], key -> new ArrayList<>()); + if (!entry.isDirectory() && ExtensionLoader.isMetaDataFile(parts[parts.length - 1])) { + if (parts.length != 2) { + throw new ZipException("Extension metadata must be directly inside its package: " + name); + } + try (InputStream metadataStream = zipFile.getInputStream(entry)) { + MetaData extension = serializer.deserialize(IOUtils.toString(metadataStream), MetaData.class); + if (!(extension instanceof PluginMetaData || extension instanceof ConnectorMetaData) + || StringUtils.isBlank(extension.getName())) { + throw new ZipException("Expected named plugin or connector metadata: " + name); + } + if (!parts[0].equals(extension.getPath())) { + throw new ZipException("Metadata path must match its package directory: " + name); + } + metadata.add(extension); + metaDataSet.add(extension); + } + } + } + if (metaDataSet.isEmpty()) { + throw new ZipException("Extension archive contains no extension metadata."); + } + Map> before = getPlannedExtensions(); + Map> after = new LinkedHashMap<>(before); + for (String path : incoming.keySet()) { + for (String installedPath : before.keySet()) { + if (path.equalsIgnoreCase(installedPath) && !path.equals(installedPath)) { + throw new ZipException("Package path must retain its installed case: " + installedPath); + } + } + } + after.putAll(incoming); + String error = getExtensionChangeError(before, after, metaDataSet, null, false); + if (error != null) { + throw new VersionMismatchException(error); + } - if (!installTempDir.exists()) { - installTempDir.mkdir(); + entries = zipFile.entries(); + while (entries.hasMoreElements()) { + extractZipEntry(entries.nextElement(), payload, zipFile); + } + } + stageExtensionPackages(payload, installTempDir, workDir); + } catch (Throwable t) { + cause = t instanceof ControllerException || t instanceof VersionMismatchException ? t : new ControllerException("Error extracting extension. " + t, t); + } finally { + // A failed rollback keeps its backups for recovery instead of deleting the last copy. + if (workDir != null && !new File(workDir, "backup").exists()) { + FileUtils.deleteQuietly(workDir); + } } + return new InstallationResult(cause, metaDataSet); + } - File tempFile = null; - FileOutputStream tempFileOutputStream = null; - ZipFile zipFile = null; - + private Map> getPlannedExtensions() throws ControllerException { + File root = new File(ExtensionController.getExtensionsPath()); + Map> inventory = extensionLoader.readExtensionMetaData(root); + File uninstall = new File(root, EXTENSIONS_UNINSTALL_FILE); try { - /* - * create a new temp file (in the install temp dir) to store the zip file contents - */ - tempFile = File.createTempFile(ServerUUIDGenerator.getUUID(), ".zip", installTempDir); - // write the contents of the multipart fileitem to the temp file - try { - tempFileOutputStream = new FileOutputStream(tempFile); - IOUtils.copy(inputStream, tempFileOutputStream); - } finally { - ResourceUtil.closeResourceQuietly(tempFileOutputStream); + if (uninstall.exists()) { + for (String path : FileUtils.readLines(uninstall)) { + inventory.remove(normalizeExtensionPath(path)); + } } + // The launcher applies pending installations after pending removals. + inventory.putAll(extensionLoader.readExtensionMetaData(new File(root, "install_temp"))); + return inventory; + } catch (IOException e) { + throw new ControllerException("Could not read pending extension changes.", e); + } + } - // create a new zip file from the temp file - zipFile = new ZipFile(tempFile); - // get a list of all of the entries in the zip file - Enumeration entries = zipFile.entries(); - - while (entries.hasMoreElements()) { - ZipEntry entry = entries.nextElement(); - String entryName = entry.getName(); - - if (entryName.endsWith("plugin.xml") || entryName.endsWith("destination.xml") || entryName.endsWith("source.xml")) { - // parse the extension metadata xml file - MetaData extensionMetaData = serializer.deserialize(IOUtils.toString(zipFile.getInputStream(entry)), MetaData.class); - metaDataSet.add(extensionMetaData); + private boolean isReservedExtensionPath(String path) { + path = path.toLowerCase(Locale.ROOT); + return path.equals("install_temp") || path.startsWith(".install-") + || path.equals(EXTENSIONS_UNINSTALL_FILE) || path.equals(EXTENSIONS_UNINSTALL_PROPERTIES_FILE.toLowerCase(Locale.ROOT)) + || path.equals(EXTENSIONS_UNINSTALL_SCRIPTS_FILE.toLowerCase(Locale.ROOT)); + } - if (!extensionLoader.isExtensionCompatible(extensionMetaData)) { - if (cause == null) { - cause = new VersionMismatchException(String.format("Extension \"%s\" is not compatible with this version of %s.", entry.getName(), BrandingConstants.PRODUCT_NAME)); + private String normalizeExtensionPath(String path) throws ControllerException { + if (path == null || path.isEmpty() || path.contains("\\")) { + throw new ControllerException("A valid extension package path is required."); + } + try { + Path relative = Paths.get(path); + for (Path part : relative) { + if (part.toString().equals("..")) { + throw new ControllerException("Invalid extension package path: " + path); + } + } + relative = relative.normalize(); + if (relative.isAbsolute() || relative.getNameCount() != 1 || relative.toString().isEmpty() + || isReservedExtensionPath(relative.toString())) { + throw new ControllerException("Invalid extension package path: " + path); + } + String name = relative.toString(); + File root = new File(ExtensionController.getExtensionsPath()); + for (File directory : new File[] { root, new File(root, "install_temp") }) { + File[] packages = directory.listFiles(File::isDirectory); + if (packages != null) { + for (File extension : packages) { + if (name.equalsIgnoreCase(extension.getName()) && !name.equals(extension.getName())) { + throw new ControllerException("Package path must retain its installed case: " + extension.getName()); } } } } + // Preserve the package basename; resolving a symlink could uninstall its target. + return name; + } catch (InvalidPathException e) { + throw new ControllerException("Invalid extension package path: " + path, e); + } + } - if (cause == null) { - // reset the entries and extract - entries = zipFile.entries(); + private List flatten(Map> inventory) { + List metadata = new ArrayList<>(); + for (List extensions : inventory.values()) { + metadata.addAll(extensions); + } + return metadata; + } - while (entries.hasMoreElements()) { - ZipEntry entry = entries.nextElement(); - extractZipEntry(entry, installTempDir, zipFile); + private String getExtensionChangeError(Map> before, Map> after, + Collection required, String changedName, boolean changedEnabled) throws ControllerException { + List previousMetadata = flatten(before); + List proposedMetadata = flatten(after); + Map previousStatus = new HashMap<>(); + List inventory = new ArrayList<>(previousMetadata); + inventory.addAll(proposedMetadata); + try { + for (MetaData metadata : inventory) { + previousStatus.computeIfAbsent(metadata.getName(), extensionStatuses::isEnabled); + } + } catch (RuntimeException e) { + throw new ControllerException("Could not read extension status; no extension changes were applied.", e); + } + Map proposedStatus = new HashMap<>(previousStatus); + if (changedName != null) { + proposedStatus.put(changedName, changedEnabled); + } + Map previousErrors = extensionLoader.getCompatibilityErrors(previousMetadata, previousStatus::get); + Map errors = extensionLoader.getCompatibilityErrors(proposedMetadata, proposedStatus::get); + for (Map.Entry error : errors.entrySet()) { + MetaData metadata = error.getKey(); + if (required.contains(metadata) || proposedStatus.get(metadata.getName()) && !previousErrors.containsKey(metadata)) { + return "Extension \"" + metadata.getName() + "\": " + error.getValue(); + } + } + return null; + } + + /** Replace complete staged packages; a retry must not leave descriptors from an older ZIP. */ + private void stageExtensionPackages(File payload, File installTempDir, File workDir) throws IOException { + File backup = new File(workDir, "backup"); + FileUtils.forceMkdir(backup); + List staged = new ArrayList<>(); + try { + for (File source : payload.listFiles()) { + File target = new File(installTempDir, source.getName()); + if (target.exists()) { + Files.move(target.toPath(), new File(backup, source.getName()).toPath()); } + Files.move(source.toPath(), target.toPath()); + staged.add(target); } - } catch (Throwable t) { - cause = new ControllerException("Error extracting extension. " + t.toString(), t); - } finally { - if (zipFile != null) { - try { - zipFile.close(); - } catch (Exception e) { - cause = new ControllerException(e); + } catch (IOException e) { + try { + for (File target : staged) { + FileUtils.deleteDirectory(target); } + for (File original : backup.listFiles()) { + Files.move(original.toPath(), new File(installTempDir, original.getName()).toPath()); + } + FileUtils.deleteDirectory(backup); + } catch (IOException rollback) { + e.addSuppressed(rollback); + throw new IOException("Could not restore pending extensions; recovery files retained at " + backup, e); } - - // delete the temp file since it is no longer needed - FileUtils.deleteQuietly(tempFile); + throw e; + } + if (!FileUtils.deleteQuietly(backup)) { + logger.warn("Extensions staged successfully, but old staging files could not be removed: {}", backup); } - - return new InstallationResult(cause, metaDataSet); } /** @@ -513,7 +685,17 @@ public InstallationResult extractExtension(InputStream inputStream) { * */ @Override - public void prepareExtensionForUninstallation(String pluginPath) throws ControllerException { + public synchronized void prepareExtensionForUninstallation(String pluginPath) throws ControllerException { + pluginPath = normalizeExtensionPath(pluginPath); + Map> before = getPlannedExtensions(); + Map> after = new LinkedHashMap<>(before); + if (!new File(new File(ExtensionController.getExtensionsPath(), "install_temp"), pluginPath).isDirectory()) { + after.remove(pluginPath); + } + String error = getExtensionChangeError(before, after, Collections.emptyList(), null, false); + if (error != null) { + throw new ControllerException(error); + } addExtensionToUninstallFile(pluginPath); for (PluginMetaData plugin : getPluginMetaData().values()) { diff --git a/server/src/main/java/com/mirth/connect/server/launcher/MirthLauncher.java b/server/src/main/java/com/mirth/connect/server/launcher/MirthLauncher.java index 5fae8beaad..434f972dcd 100644 --- a/server/src/main/java/com/mirth/connect/server/launcher/MirthLauncher.java +++ b/server/src/main/java/com/mirth/connect/server/launcher/MirthLauncher.java @@ -17,7 +17,10 @@ import java.net.URLClassLoader; import java.util.ArrayList; import java.util.Collection; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.function.Predicate; import java.util.Properties; import java.util.jar.JarFile; @@ -32,9 +35,10 @@ import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; -import org.w3c.dom.NodeList; -import com.mirth.connect.client.core.ExtensionCompatibility; +import com.mirth.connect.client.core.ExtensionDependencies; +import com.mirth.connect.client.core.ExtensionDependencies.Extension; +import com.mirth.connect.client.core.ExtensionDependency; import com.mirth.connect.server.extprops.ExtensionStatuses; import com.mirth.connect.server.extprops.LoggerWrapper; @@ -231,72 +235,165 @@ private static void addManifestToClasspath(ManifestEntry[] manifestEntries, List } private static void addExtensionsToClasspath(List urls, String currentVersion) throws Exception { + addExtensionsToClasspath(urls, currentVersion, new File(EXTENSIONS_DIR), ExtensionStatuses.getInstance()::isEnabled); + } + + static void addExtensionsToClasspath(List urls, String currentVersion, File extensionPath, + Predicate enabled) throws Exception { FileFilter extensionFileFilter = new NameFileFilter(new String[] { "plugin.xml", "source.xml", "destination.xml" }, IOCase.INSENSITIVE); - FileFilter directoryFilter = FileFilterUtils.directoryFileFilter(); - File extensionPath = new File(EXTENSIONS_DIR); - - ExtensionStatuses extensionStatuses = ExtensionStatuses.getInstance(); - - if (extensionPath.exists() && extensionPath.isDirectory()) { - File[] directories = extensionPath.listFiles(directoryFilter); - - for (File directory : directories) { - File[] extensionFiles = directory.listFiles(extensionFileFilter); - - for (File extensionFile : extensionFiles) { - try { - DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); - dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); - Document document = dbf.newDocumentBuilder().parse(extensionFile); - Element rootElement = document.getDocumentElement(); - - boolean enabled = extensionStatuses.isEnabled(rootElement.getElementsByTagName("name").item(0).getTextContent()); - boolean compatible = isExtensionCompatible(rootElement, currentVersion); - - // Only add libraries from extensions that are not disabled and are compatible with the current version - if (enabled && compatible) { - NodeList libraries = rootElement.getElementsByTagName("library"); - - for (int i = 0; i < libraries.getLength(); i++) { - Element libraryElement = (Element) libraries.item(i); - String type = libraryElement.getAttribute("type"); - - if (type.equalsIgnoreCase("server") || type.equalsIgnoreCase("shared")) { - File pathFile = new File(directory, libraryElement.getAttribute("path")); - - if (pathFile.exists()) { - logger.trace("adding library to classpath: " + pathFile.getAbsolutePath()); - urls.add(pathFile.toURI().toURL()); - } else { - logger.error("could not locate library: " + pathFile.getAbsolutePath()); - } - } - } - } - } catch (Exception e) { - logger.error("failed to parse extension metadata: " + extensionFile.getAbsolutePath(), e); + File[] directories = extensionPath.listFiles((FileFilter) FileFilterUtils.directoryFileFilter()); + if (directories == null) { + logger.warn("no extensions found"); + return; + } + + // Resolve the complete inventory before adding libraries, regardless of filesystem order. + Map metadata = new LinkedHashMap<>(); + Map paths = new LinkedHashMap<>(); + for (File directory : directories) { + if ("install_temp".equals(directory.getName()) || directory.getName().startsWith(".install-")) { + continue; + } + File[] extensionFiles = directory.listFiles(extensionFileFilter); + if (extensionFiles == null) { + continue; + } + for (File extensionFile : extensionFiles) { + try { + DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance(); + dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + Document document = dbf.newDocumentBuilder().parse(extensionFile); + Element root = document.getDocumentElement(); + String name = getMetadataValue(root, "name"); + if (name == null || name.trim().isEmpty()) { + throw new IllegalArgumentException("Extension metadata must declare a name"); + } + Extension extension = readExtension(root, enabled.test(name)); + metadata.put(extension, root); + paths.put(extension, directory); + } catch (Exception e) { + logger.error("failed to parse extension metadata: " + extensionFile.getAbsolutePath(), e); + } + } + } + + Map errors = ExtensionDependencies.validate(metadata.keySet(), currentVersion); + for (Map.Entry entry : metadata.entrySet()) { + Extension extension = entry.getKey(); + if (errors.containsKey(extension)) { + logger.error("could not load extension " + extension.getName() + ": " + errors.get(extension)); + continue; + } + if (!extension.isEnabled()) { + continue; + } + for (Node child = entry.getValue().getFirstChild(); child != null; child = child.getNextSibling()) { + if (!(child instanceof Element) || !"library".equals(child.getNodeName())) { + continue; + } + Element library = (Element) child; + String type = library.getAttribute("type"); + if (type.equalsIgnoreCase("server") || type.equalsIgnoreCase("shared")) { + File pathFile = new File(paths.get(extension), library.getAttribute("path")); + if (pathFile.exists()) { + logger.trace("adding library to classpath: " + pathFile.getAbsolutePath()); + urls.add(pathFile.toURI().toURL()); + } else { + logger.error("could not locate library: " + pathFile.getAbsolutePath()); } } } - } else { - logger.warn("no extensions found"); } } static boolean isExtensionCompatible(Element metadata, String currentVersion) { - return ExtensionCompatibility.isCompatible(getMetadataValue(metadata, "mirthVersion"), - getMetadataValue(metadata, "minExtensionApiVersion"), currentVersion); + try { + return ExtensionDependencies.getEngineError(readExtension(metadata, true), currentVersion) == null; + } catch (IllegalArgumentException e) { + return false; + } + } + + static Extension readExtension(Element metadata, boolean enabled) { + String root = metadata.getNodeName(); + if (!"pluginMetaData".equals(root) && !"connectorMetaData".equals(root)) { + throw new IllegalArgumentException("Unknown extension metadata type: " + root); + } + String metadataType = metadata.hasAttribute("resolves-to") ? metadata.getAttribute("resolves-to") : metadata.getAttribute("class"); + String className = "com.mirth.connect.model." + ("pluginMetaData".equals(root) ? "PluginMetaData" : "ConnectorMetaData"); + if (!metadataType.isEmpty() && !root.equals(metadataType) && !className.equals(metadataType)) { + throw new IllegalArgumentException("Extension metadata type must match its root element"); + } + List dependencies = new ArrayList<>(); + boolean foundDependencies = false; + for (Node child = metadata.getFirstChild(); child != null; child = child.getNextSibling()) { + if (!(child instanceof Element) || !"dependencies".equals(child.getNodeName())) { + continue; + } + if (foundDependencies) { + throw new IllegalArgumentException("Duplicate dependencies list"); + } + foundDependencies = true; + Element list = (Element) child; + if (isNull(list)) { + continue; + } + for (Node item = list.getFirstChild(); item != null; item = item.getNextSibling()) { + if (!(item instanceof Element)) { + if ((item.getNodeType() == Node.TEXT_NODE || item.getNodeType() == Node.CDATA_SECTION_NODE) + && !item.getNodeValue().trim().isEmpty()) { + throw new IllegalArgumentException("Dependencies must contain dependency elements"); + } + continue; + } + Element dependency = (Element) item; + if (!"dependency".equals(dependency.getNodeName())) { + throw new IllegalArgumentException("Unknown dependency element: " + dependency.getNodeName()); + } + for (int i = 0; i < dependency.getAttributes().getLength(); i++) { + String name = dependency.getAttributes().item(i).getNodeName(); + if (!"type".equals(name) && !"name".equals(name) && !"minVersion".equals(name)) { + throw new IllegalArgumentException("Unknown dependency attribute: " + name); + } + } + for (Node value = dependency.getFirstChild(); value != null; value = value.getNextSibling()) { + if (value instanceof Element || ((value.getNodeType() == Node.TEXT_NODE + || value.getNodeType() == Node.CDATA_SECTION_NODE) && !value.getNodeValue().trim().isEmpty())) { + throw new IllegalArgumentException("Dependency values must be attributes"); + } + } + dependencies.add(new ExtensionDependency(attribute(dependency, "type"), + attribute(dependency, "name"), attribute(dependency, "minVersion"))); + } + } + return new Extension(getMetadataValue(metadata, "name"), "pluginMetaData".equals(root), + getMetadataValue(metadata, "pluginVersion"), getMetadataValue(metadata, "mirthVersion"), + getMetadataValue(metadata, "minExtensionApiVersion"), dependencies, enabled); + } + + private static String attribute(Element element, String name) { + return element.hasAttribute(name) ? element.getAttribute(name) : null; + } + + private static boolean isNull(Element element) { + String type = element.hasAttribute("resolves-to") ? element.getAttribute("resolves-to") : element.getAttribute("class"); + return "null".equals(type) || "com.thoughtworks.xstream.mapper.Mapper$Null".equals(type); } private static String getMetadataValue(Element metadata, String name) { + String result = null; + boolean found = false; for (Node child = metadata.getFirstChild(); child != null; child = child.getNextSibling()) { if (child instanceof Element && name.equals(child.getNodeName())) { + if (found) { + throw new IllegalArgumentException("Duplicate metadata field: " + name); + } + found = true; // Match XStream's null and scalar text handling without loading XStream here. Element element = (Element) child; - String type = element.hasAttribute("resolves-to") ? element.getAttribute("resolves-to") : element.getAttribute("class"); - if ("null".equals(type) || "com.thoughtworks.xstream.mapper.Mapper$Null".equals(type)) { - return null; + if (isNull(element)) { + continue; } StringBuilder text = new StringBuilder(); for (Node value = child.getFirstChild(); value != null; value = value.getNextSibling()) { @@ -304,10 +401,10 @@ private static String getMetadataValue(Element metadata, String name) { text.append(value.getNodeValue()); } } - return text.toString(); + result = text.toString(); } } - return null; + return result; } private static void createAppdataDir(Properties mirthProperties) { diff --git a/server/src/test/java/com/mirth/connect/client/core/ExtensionDependenciesTest.java b/server/src/test/java/com/mirth/connect/client/core/ExtensionDependenciesTest.java new file mode 100644 index 0000000000..c7c56ba94c --- /dev/null +++ b/server/src/test/java/com/mirth/connect/client/core/ExtensionDependenciesTest.java @@ -0,0 +1,235 @@ +/* + * Copyright (c) Open Integration Engine contributors. + * Licensed under the Mozilla Public License 2.0. + */ + +package com.mirth.connect.client.core; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.junit.Test; + +import com.mirth.connect.client.core.ExtensionDependencies.Extension; + +public class ExtensionDependenciesTest { + private static final String SERVER_VERSION = "4.5.2"; + + @Test + public void engineRequirementUsesIndependentApiVersion() { + Extension extension = descriptor("Consumer", true, "1.0.0", "old release", null, true, + requirement("engine-api", null, "1.0.0")); + assertNull(ExtensionDependencies.getEngineError(extension, "99.0.0")); + assertError(descriptor("Consumer", true, "1.0.0", SERVER_VERSION, null, true, + requirement("engine-api", null, "1.0.1")), "Requires engine API"); + assertError(descriptor("Consumer", true, "1.0.0", SERVER_VERSION, null, true, + requirement("engine-api", null, "2.0.0")), "same major version"); + assertNull(ExtensionDependencies.getEngineError(plugin("Consumer"), "99.0.0")); + } + + @Test + public void emptyAndPluginOnlyDependenciesPreserveLegacyEngineGate() { + Extension provider = plugin("Provider"); + Extension empty = descriptor("Empty", true, "1.0.0", "old release", null, true); + Extension consumer = descriptor("Consumer", true, "1.0.0", "old release", null, true, + pluginRequirement("Provider", "1.0.0")); + Map errors = validate(provider, empty, consumer); + assertEquals(2, errors.size()); + assertTrue(errors.get(empty).contains("engine release")); + assertTrue(errors.get(consumer).contains("engine release")); + Extension legacy = descriptor("Legacy", true, "arbitrary", SERVER_VERSION, null, true, + pluginRequirement("Provider", "1.0.0")); + assertTrue(validate(legacy, provider).isEmpty()); + assertNull(ExtensionDependencies.getEngineError(legacy, SERVER_VERSION + ".123")); + } + + @Test + public void invalidAndConflictingDeclarationsFailClosed() { + for (String invalid : new String[] { null, "", "1", "1.0", "1.0.0.0", "01.0.0", "1.0.0-beta", + "1.0.0+build", "1.0.*", "-1.0.0", "2147483648.0.0" }) { + assertError(plugin("Consumer", pluginRequirement("Provider", invalid)), "numeric major.minor.patch"); + } + assertError(plugin("Consumer", (ExtensionDependency) null), "must not be null"); + for (String type : new String[] { null, "", "Plugin", "engine", "optional" }) { + assertError(plugin("Consumer", requirement(type, "Provider", "1.0.0")), "Unknown dependency type"); + } + for (String name : new String[] { null, "", " \t " }) { + assertError(plugin("Consumer", pluginRequirement(name, "1.0.0")), "exact metadata name"); + } + assertError(descriptor("Consumer", true, "1.0.0", SERVER_VERSION, null, true, + requirement("engine-api", "", "1.0.0")), "must not declare a name"); + assertError(plugin("Consumer", requirement("engine-api", null, "1.0.0")), "only once"); + assertError(descriptor("Consumer", true, "1.0.0", SERVER_VERSION, null, true, + requirement("engine-api", null, "1.0.0"), requirement("engine-api", null, "1.0.0")), "only once"); + assertError(plugin("Consumer", pluginRequirement("Provider", "1.0.0"), + pluginRequirement("Provider", "1.2.0")), "more than once"); + assertError(plugin("Consumer", pluginRequirement("Consumer", "1.0.0")), "depend on itself"); + } + + @Test + public void pluginRequirementsUseNumericSameMajorMinimumVersions() { + Extension consumer = plugin("Consumer", pluginRequirement("Provider", "1.2.3")); + for (String version : new String[] { "1.2.3", "1.2.10", "1.10.0", " 1.3.0 " }) { + assertTrue(version, validate(consumer, versionedPlugin("Provider", version)).isEmpty()); + } + for (String version : new String[] { null, "", "1.2.2", "1.1.99", "0.99.99", "2.0.0", "1.2.3.4", + "1.2.3-beta", "01.2.3", "1.2147483648.0" }) { + Map errors = validate(consumer, versionedPlugin("Provider", version)); + assertEquals(String.valueOf(version), 1, errors.size()); + assertTrue(errors.get(consumer).contains("installed version")); + } + // An unreferenced plugin does not need to change its existing version format. + assertTrue(validate(versionedPlugin("Unreferenced", "release-five")).isEmpty()); + } + + @Test + public void dependenciesRequireAnEnabledPluginWithTheExactName() { + Extension consumer = plugin("Consumer", pluginRequirement("Provider", "1.0.0")); + assertTrue(validate(consumer).get(consumer).contains("not installed")); + assertTrue(validate(consumer, plugin("provider")).get(consumer).contains("not installed")); + assertTrue(validate(consumer, plugin(" Provider ")).get(consumer).contains("not installed")); + Extension connector = descriptor("Provider", false, "1.0.0", null, "1.0.0", true); + assertTrue(validate(consumer, connector).get(consumer).contains("not installed")); + Extension disabled = descriptor("Provider", true, "1.0.0", null, "1.0.0", false); + assertTrue(validate(consumer, disabled).get(consumer).contains("disabled")); + + Extension connectorConsumer = descriptor("Provider", false, "1.0.0", null, "1.0.0", true, + pluginRequirement("Provider", "1.0.0")); + assertTrue(validate(connectorConsumer, plugin("Provider")).isEmpty()); + } + + @Test + public void allRequirementsMustBeSatisfied() { + Extension consumer = plugin("Consumer", pluginRequirement("First", "1.0.0"), + pluginRequirement("Second", "2.0.0")); + Extension first = plugin("First"); + assertTrue(validate(consumer, first).get(consumer).contains("Second")); + assertTrue(validate(consumer, first, versionedPlugin("Second", "2.1.0")).isEmpty()); + assertTrue(validate(consumer, first, versionedPlugin("Second", "1.0.0")).get(consumer).contains("Second")); + } + + @Test + public void duplicatePluginNamesRejectEveryProviderAndTheirConsumers() { + Extension first = plugin("Provider"); + Extension second = descriptor("Provider", true, "1.0.0", null, "1.0.0", false); + Extension consumer = plugin("Consumer", pluginRequirement("Provider", "1.0.0")); + Map errors = validate(consumer, first, second); + assertEquals(3, errors.size()); + assertTrue(errors.get(first).contains("More than one plugin")); + assertTrue(errors.get(second).contains("More than one plugin")); + assertTrue(errors.get(consumer).contains("ambiguous")); + } + + @Test + public void disabledConsumersKeepDeclarationAndEngineChecksButMayHaveMissingPlugins() { + Extension disabled = descriptor("Disabled", true, "1.0.0", null, "1.0.0", false, + pluginRequirement("Missing", "1.0.0")); + assertTrue(validate(disabled).isEmpty()); + Extension badDeclaration = descriptor("Disabled", true, "1.0.0", null, "1.0.0", false, + pluginRequirement("Missing", "bad version")); + assertTrue(validate(badDeclaration).get(badDeclaration).contains("numeric major.minor.patch")); + Extension badEngine = descriptor("Disabled", true, "1.0.0", null, "2.0.0", false, + pluginRequirement("Missing", "1.0.0")); + assertTrue(validate(badEngine).get(badEngine).contains("Requires engine API")); + } + + @Test + public void incompatibleProvidersInvalidateTransitiveConsumersInAnyInventoryOrder() { + Extension first = plugin("First", pluginRequirement("Second", "1.0.0")); + Extension second = plugin("Second", pluginRequirement("Third", "1.0.0")); + for (Extension third : Arrays.asList( + descriptor("Third", true, "1.0.0", null, "2.0.0", true), + plugin("Third", pluginRequirement("Missing", "1.0.0")), + plugin("Third", requirement("invalid", null, "1.0.0")))) { + Map forward = validate(first, second, third); + Map reverse = validate(third, second, first); + assertEquals(3, forward.size()); + assertEquals(forward, reverse); + assertTrue(forward.get(first).contains("Second")); + assertTrue(forward.get(second).contains("Third")); + assertEquals(Arrays.asList(first, second, third), new ArrayList<>(forward.keySet())); + assertEquals(Arrays.asList(third, second, first), new ArrayList<>(reverse.keySet())); + } + } + + @Test + public void cyclesAndTheirConsumersFailWithoutBlockingIndependentPlugins() { + Extension first = plugin("First", pluginRequirement("Second", "1.0.0")); + Extension second = plugin("Second", pluginRequirement("First", "1.0.0")); + Extension consumer = plugin("Consumer", pluginRequirement("First", "1.0.0")); + Extension independent = plugin("Independent"); + Map errors = validate(consumer, first, second, independent); + assertEquals(3, errors.size()); + for (String error : errors.values()) { + assertTrue(error.contains("Circular plugin dependencies")); + } + assertFalse(errors.containsKey(independent)); + } + + @Test + public void failureInsideACyclePropagatesAndDiamondDependenciesResolve() { + Extension first = plugin("First", pluginRequirement("Second", "1.0.0")); + Extension second = plugin("Second", pluginRequirement("First", "1.0.0"), + pluginRequirement("Missing", "1.0.0")); + Map errors = validate(first, second); + assertTrue(errors.get(first).contains("Second")); + assertTrue(errors.get(second).contains("Missing")); + + Extension base = plugin("Base"); + Extension left = plugin("Left", pluginRequirement("Base", "1.0.0")); + Extension right = plugin("Right", pluginRequirement("Base", "1.0.0")); + Extension top = plugin("Top", pluginRequirement("Left", "1.0.0"), pluginRequirement("Right", "1.0.0")); + assertTrue(validate(top, left, right, base).isEmpty()); + } + + @Test + public void longDependencyChainsDoNotUseTheCallStack() { + List chain = new ArrayList<>(); + int length = 10000; + for (int index = 0; index < length - 1; index++) { + chain.add(plugin("Plugin " + index, pluginRequirement("Plugin " + (index + 1), "1.0.0"))); + } + chain.add(plugin("Plugin " + (length - 1))); + assertTrue(ExtensionDependencies.validate(chain, SERVER_VERSION).isEmpty()); + chain.set(length - 1, plugin("Plugin " + (length - 1), pluginRequirement("Missing", "1.0.0"))); + assertEquals(length, ExtensionDependencies.validate(chain, SERVER_VERSION).size()); + } + + private static ExtensionDependency requirement(String type, String name, String version) { + return new ExtensionDependency(type, name, version); + } + + private static ExtensionDependency pluginRequirement(String name, String version) { + return requirement("plugin", name, version); + } + + private static Extension plugin(String name, ExtensionDependency... dependencies) { + return descriptor(name, true, "1.0.0", null, "1.0.0", true, dependencies); + } + + private static Extension versionedPlugin(String name, String version) { + return descriptor(name, true, version, null, "1.0.0", true); + } + + private static Extension descriptor(String name, boolean plugin, String pluginVersion, String mirthVersion, + String minApiVersion, boolean enabled, ExtensionDependency... dependencies) { + return new Extension(name, plugin, pluginVersion, mirthVersion, minApiVersion, + dependencies == null ? null : Arrays.asList(dependencies), enabled); + } + + private static Map validate(Extension... extensions) { + return ExtensionDependencies.validate(Arrays.asList(extensions), SERVER_VERSION); + } + + private static void assertError(Extension extension, String expected) { + String error = ExtensionDependencies.getEngineError(extension, SERVER_VERSION); + assertTrue(String.valueOf(error), error != null && error.contains(expected)); + } +} diff --git a/server/src/test/java/com/mirth/connect/model/ExtensionMetaDataTest.java b/server/src/test/java/com/mirth/connect/model/ExtensionMetaDataTest.java index c986dbfc10..a75cb612e8 100644 --- a/server/src/test/java/com/mirth/connect/model/ExtensionMetaDataTest.java +++ b/server/src/test/java/com/mirth/connect/model/ExtensionMetaDataTest.java @@ -8,15 +8,53 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import java.util.ArrayList; +import java.util.Arrays; + import org.junit.Test; +import com.mirth.connect.client.core.ExtensionDependency; import com.mirth.connect.model.converters.ObjectXMLSerializer; public class ExtensionMetaDataTest { private final ObjectXMLSerializer serializer = new ObjectXMLSerializer(getClass().getClassLoader()); + @Test + public void dependenciesRoundTripForPluginsAndConnectors() { + for (MetaData metadata : new MetaData[] { new PluginMetaData(), new ConnectorMetaData() }) { + metadata.setDependencies(new ArrayList<>(Arrays.asList( + new ExtensionDependency("engine-api", null, "1.0.0"), + new ExtensionDependency("plugin", "Provider & Support", "2.1.0")))); + String xml = serializer.serialize(metadata); + assertTrue(xml.contains("")); + assertTrue(xml.contains("")); + MetaData restored = serializer.deserialize(xml, MetaData.class); + assertEquals(2, restored.getDependencies().size()); + assertEquals("engine-api", restored.getDependencies().get(0).getType()); + assertNull(restored.getDependencies().get(0).getName()); + assertEquals("Provider & Support", restored.getDependencies().get(1).getName()); + assertEquals("2.1.0", restored.getDependencies().get(1).getMinVersion()); + } + } + + @Test + public void dependencySchemaRejectsTyposAndNestedFields() { + for (String dependency : new String[] { + "", + "pluginProvider1.0.0", + "", + "unexpected", + "unexpected", "" }) { + for (String root : new String[] { "pluginMetaData", "connectorMetaData" }) { + String xml = "<" + root + ">" + dependency + ""; + assertThrows(xml, RuntimeException.class, () -> serializer.deserialize(xml, MetaData.class)); + } + } + } + @Test public void apiLockRoundTripsForPluginsAndConnectors() { for (MetaData metadata : new MetaData[] { new PluginMetaData(), new ConnectorMetaData() }) { diff --git a/server/src/test/java/com/mirth/connect/server/controllers/ExtensionDependencyInstallationTest.java b/server/src/test/java/com/mirth/connect/server/controllers/ExtensionDependencyInstallationTest.java new file mode 100644 index 0000000000..0c8183db17 --- /dev/null +++ b/server/src/test/java/com/mirth/connect/server/controllers/ExtensionDependencyInstallationTest.java @@ -0,0 +1,538 @@ +/* + * Copyright (c) Open Integration Engine contributors. + * Licensed under the Mozilla Public License 2.0. + */ + +package com.mirth.connect.server.controllers; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.junit.Assume.assumeNoException; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; + +import com.mirth.connect.client.core.ControllerException; +import com.mirth.connect.model.converters.ObjectXMLSerializer; +import com.mirth.connect.server.ExtensionLoader; +import com.mirth.connect.server.controllers.ExtensionController.InstallationResult; +import com.mirth.connect.server.extprops.ExtensionStatuses; +import com.mirth.connect.server.tools.ClassPathResource; +import com.mirth.connect.server.util.ResourceUtil; + +/** Exercises installation and administrative actions without an engine or database. */ +public class ExtensionDependencyInstallationTest { + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private final List> staticMocks = new ArrayList<>(); + private final Map enabled = new HashMap<>(); + private Path extensions; + private DefaultExtensionController controller; + private ExtensionStatuses statuses; + private MockedStatic resources; + + @Before + public void setUp() throws Exception { + extensions = temporaryFolder.newFolder("extensions").toPath(); + ObjectXMLSerializer serializer = new ObjectXMLSerializer(getClass().getClassLoader()); + mockGlobal(ObjectXMLSerializer.class).when(ObjectXMLSerializer::getInstance).thenReturn(serializer); + + statuses = mock(ExtensionStatuses.class); + when(statuses.isEnabled(anyString())).thenAnswer(call -> enabled.getOrDefault(call.getArgument(0), true)); + doAnswer(call -> { + enabled.put(call.getArgument(0), call.getArgument(1)); + return null; + }).when(statuses).setEnabled(anyString(), anyBoolean()); + mockGlobal(ExtensionStatuses.class).when(ExtensionStatuses::getInstance).thenReturn(statuses); + + Constructor constructor = ExtensionLoader.class.getDeclaredConstructor(); + constructor.setAccessible(true); + ExtensionLoader loader = constructor.newInstance(); + mockGlobal(ExtensionLoader.class).when(ExtensionLoader::getInstance).thenReturn(loader); + + ControllerFactory factory = mock(ControllerFactory.class); + when(factory.createConfigurationController()).thenReturn(mock(ConfigurationController.class)); + mockGlobal(ControllerFactory.class).when(ControllerFactory::getFactory).thenReturn(factory); + mockGlobal(ExtensionController.class).when(ExtensionController::getExtensionsPath) + .thenAnswer(call -> extensions.toString()); + mockGlobal(ClassPathResource.class).when(() -> ClassPathResource.getResourceURI("extensions")) + .thenAnswer(call -> extensions.toUri()); + resources = mockGlobal(ResourceUtil.class); + resources.when(() -> ResourceUtil.getResourceStream(ExtensionLoader.class, "version.properties")) + .thenAnswer(call -> new ByteArrayInputStream("mirth.version=4.5.2.123".getBytes(StandardCharsets.UTF_8))); + controller = new DefaultExtensionController(); + } + + @After + public void closeMocks() { + Collections.reverse(staticMocks); + staticMocks.forEach(MockedStatic::close); + } + + private MockedStatic mockGlobal(Class type) { + MockedStatic mocked = mockStatic(type, CALLS_REAL_METHODS); + staticMocks.add(mocked); + return mocked; + } + + @Test + public void bundledProvidersResolveRegardlessOfZipEntryOrder() throws Exception { + for (boolean providerFirst : new boolean[] { false, true }) { + extensions = temporaryFolder.newFolder().toPath(); + Map archive = new LinkedHashMap<>(); + if (providerFirst) { + archive.putAll(plugin("provider", "Provider", "2.1.0")); + } + archive.putAll(plugin("consumer", "Consumer", "1.0.0", "Provider")); + if (!providerFirst) { + archive.putAll(plugin("provider", "Provider", "2.1.0")); + } + assertAccepted(install(archive)); + assertEquals("Consumer", Files.readString(staged("consumer/payload.txt"))); + assertEquals("Provider", Files.readString(staged("provider/payload.txt"))); + } + } + + @Test + public void installedAndPreviouslyStagedProvidersAreAvailable() throws Exception { + writeInstalled(plugin("installed", "Installed", "2.1.0")); + assertAccepted(install(plugin("staged", "Staged", "2.1.0"))); + assertAccepted(install(plugin("consumer", "Consumer", "1.0.0", "Installed", "Staged"))); + assertTrue(Files.exists(staged("consumer/plugin.xml"))); + } + + @Test + public void invalidInstalledDescriptorsDoNotHideUnrelatedPluginMetadata() throws Exception { + writeInstalled(plugin("provider", "Provider", "2.1.0")); + String nameless = plugin("nameless", "Nameless", "1.0.0").get("nameless/plugin.xml") + .replace("Nameless", ""); + writeInstalled(Map.of("null/plugin.xml", "", "foreign/plugin.xml", "Unexpected type", + "nameless/plugin.xml", nameless)); + assertEquals(Set.of("Provider"), controller.getPluginMetaData().keySet()); + } + + @Test + public void statusLookupFailureDoesNotHideUnrelatedPluginMetadata() throws Exception { + writeInstalled(plugin("provider", "Provider", "2.1.0")); + writeInstalled(plugin("broken", "Broken", "2.1.0")); + when(statuses.isEnabled("Broken")).thenThrow(new IllegalStateException("Status unavailable for Broken")); + assertEquals(Set.of("Provider"), controller.getPluginMetaData().keySet()); + } + + @Test + public void connectorRequirementsUseTheSameArchiveInventory() throws Exception { + for (String file : new String[] { "source.xml", "destination.xml" }) { + extensions = temporaryFolder.newFolder().toPath(); + Map archive = plugin("connector", "Connector", "1.0.0", "Provider"); + String metadata = archive.remove("connector/plugin.xml").replace("pluginMetaData", "connectorMetaData"); + archive.put("connector/" + file, metadata); + assertRejected(install(archive), "Provider"); + assertTrue(stagedFiles().isEmpty()); + archive.putAll(plugin("provider", "Provider", "2.1.0")); + assertAccepted(install(archive)); + assertTrue(Files.exists(staged("connector/" + file))); + } + } + + @Test + public void pendingRemovalIsAppliedBeforePendingInstallation() throws Exception { + writeInstalled(plugin("provider", "Provider", "2.1.0")); + Files.writeString(extensions.resolve("uninstall"), "provider\n"); + assertRejected(install(plugin("consumer", "Consumer", "1.0.0", "Provider")), "Provider"); + assertFalse(Files.exists(staged("consumer/payload.txt"))); + assertAccepted(install(plugin("provider", "Provider", "2.2.0"))); + assertAccepted(install(plugin("consumer", "Consumer", "1.0.0", "Provider"))); + } + + @Test + public void aliasedPendingRemovalStillExcludesProviderFromNewInstallation() throws Exception { + writeInstalled(plugin("provider", "Provider", "2.1.0")); + Files.writeString(extensions.resolve("uninstall"), "./provider/\n"); + assertRejected(install(plugin("consumer", "Consumer", "1.0.0", "Provider")), "Provider"); + assertFalse(Files.exists(staged("consumer/plugin.xml"))); + } + + @Test + public void disabledProviderRejectsConsumerUntilCorrected() throws Exception { + writeInstalled(plugin("provider", "Provider", "2.1.0")); + enabled.put("Provider", false); + assertRejected(install(plugin("consumer", "Consumer", "1.0.0", "Provider")), "Provider"); + assertFalse(Files.exists(staged("consumer/payload.txt"))); + controller.setExtensionEnabled("Provider", true); + assertAccepted(install(plugin("consumer", "Consumer", "1.0.0", "Provider"))); + } + + @Test + public void disabledConsumerDefersDependenciesUntilEnable() throws Exception { + enabled.put("Consumer", false); + assertAccepted(install(plugin("consumer", "Consumer", "1.0.0", "Provider"))); + assertActionRejected(() -> controller.setExtensionEnabled("Consumer", true), "Provider"); + assertFalse(controller.isExtensionEnabled("Consumer")); + assertAccepted(install(plugin("provider", "Provider", "2.1.0"))); + controller.setExtensionEnabled("Consumer", true); + assertTrue(controller.isExtensionEnabled("Consumer")); + } + + @Test + public void providerUpdateCannotBreakExistingConsumerOrReplacePriorStaging() throws Exception { + writeInstalled(plugin("consumer", "Consumer", "1.0.0", "Provider")); + assertAccepted(install(plugin("provider", "Provider", "2.1.0"))); + Map before = stagedFiles(); + assertRejected(install(plugin("provider", "Provider", "3.0.0")), "Consumer"); + assertEquals(before, stagedFiles()); + assertAccepted(install(plugin("provider", "Provider", "2.2.0"))); + } + + @Test + public void disableChecksCurrentAndStagedConsumersBeforeChangingStatus() throws Exception { + writeInstalled(plugin("provider", "Provider", "2.1.0")); + assertAccepted(install(plugin("consumer", "Consumer", "1.0.0", "Provider"))); + assertActionRejected(() -> controller.setExtensionEnabled("Provider", false), "Consumer"); + assertTrue(controller.isExtensionEnabled("Provider")); + controller.setExtensionEnabled("Consumer", false); + controller.setExtensionEnabled("Provider", false); + assertFalse(controller.isExtensionEnabled("Provider")); + assertActionRejected(() -> controller.setExtensionEnabled("Consumer", true), "Provider"); + assertFalse(controller.isExtensionEnabled("Consumer")); + } + + @Test + public void uninstallRequiresConsumersFirstAndDoesNotWriteOnRejection() throws Exception { + writeInstalled(plugin("provider", "Provider", "2.1.0")); + writeInstalled(plugin("consumer", "Consumer", "1.0.0", "Provider")); + assertActionRejected(() -> controller.prepareExtensionForUninstallation("provider"), "Consumer"); + assertFalse(Files.exists(extensions.resolve("uninstall"))); + assertFalse(Files.exists(extensions.resolve(ExtensionController.EXTENSIONS_UNINSTALL_PROPERTIES_FILE))); + controller.prepareExtensionForUninstallation("consumer"); + controller.prepareExtensionForUninstallation("provider"); + assertEquals(List.of("consumer", "provider"), Files.readAllLines(extensions.resolve("uninstall"))); + } + + @Test + public void statusPersistenceFailureRestoresMemoryAndAllowsRetry() throws Exception { + writeInstalled(plugin("provider", "Provider", "2.1.0")); + doThrow(new IllegalStateException("Status storage unavailable")).when(statuses).save(); + assertActionRejected(() -> controller.setExtensionEnabled("Provider", false), "Could not save"); + assertTrue(controller.isExtensionEnabled("Provider")); + doNothing().when(statuses).save(); + controller.setExtensionEnabled("Provider", false); + assertFalse(controller.isExtensionEnabled("Provider")); + } + + @Test + public void unavailableVersionResourceRejectsEveryMutationUntilRetry() throws Exception { + writeInstalled(plugin("provider", "Provider", "2.1.0")); + resources.when(() -> ResourceUtil.getResourceStream(ExtensionLoader.class, "version.properties")) + .thenThrow(new FileNotFoundException("Version resource unavailable")); + assertRejected(install(plugin("independent", "Independent", "1.0.0")), "Could not determine extension compatibility"); + assertActionRejected(() -> controller.setExtensionEnabled("Provider", false), "Could not determine extension compatibility"); + assertActionRejected(() -> controller.prepareExtensionForUninstallation("provider"), "Could not determine extension compatibility"); + assertTrue(stagedFiles().isEmpty()); + assertTrue(controller.isExtensionEnabled("Provider")); + assertFalse(Files.exists(extensions.resolve("uninstall"))); + resources.when(() -> ResourceUtil.getResourceStream(ExtensionLoader.class, "version.properties")) + .thenAnswer(call -> new ByteArrayInputStream("mirth.version=4.5.2.123".getBytes(StandardCharsets.UTF_8))); + assertAccepted(install(plugin("independent", "Independent", "1.0.0"))); + controller.setExtensionEnabled("Provider", false); + controller.prepareExtensionForUninstallation("provider"); + assertFalse(controller.isExtensionEnabled("Provider")); + assertEquals(List.of("provider"), Files.readAllLines(extensions.resolve("uninstall"))); + } + + @Test + public void unavailableStatusRejectsEveryMutationUntilRetry() throws Exception { + writeInstalled(plugin("provider", "Provider", "2.1.0")); + when(statuses.isEnabled("Provider")).thenThrow(new IllegalStateException("Status unavailable")); + assertRejected(install(plugin("independent", "Independent", "1.0.0")), "Could not read extension status"); + assertActionRejected(() -> controller.setExtensionEnabled("Provider", false), "Could not read extension status"); + assertActionRejected(() -> controller.prepareExtensionForUninstallation("provider"), "Could not read extension status"); + assertTrue(stagedFiles().isEmpty()); + assertFalse(enabled.containsKey("Provider")); + assertFalse(Files.exists(extensions.resolve("uninstall"))); + doAnswer(call -> enabled.getOrDefault("Provider", true)).when(statuses).isEnabled("Provider"); + assertAccepted(install(plugin("independent", "Independent", "1.0.0"))); + controller.setExtensionEnabled("Provider", false); + controller.prepareExtensionForUninstallation("provider"); + assertFalse(controller.isExtensionEnabled("Provider")); + assertEquals(List.of("provider"), Files.readAllLines(extensions.resolve("uninstall"))); + } + + @Test + public void uninstallAliasesCannotBypassConsumerProtection() throws Exception { + writeInstalled(plugin("provider", "Provider", "2.1.0")); + writeInstalled(plugin("consumer", "Consumer", "1.0.0", "Provider")); + for (String alias : new String[] { "provider/", "./provider", "./provider/" }) { + assertActionRejected(() -> controller.prepareExtensionForUninstallation(alias), "Consumer"); + assertFalse(Files.exists(extensions.resolve("uninstall"))); + } + controller.setExtensionEnabled("Consumer", false); + controller.prepareExtensionForUninstallation("./provider/"); + assertEquals(List.of("provider"), Files.readAllLines(extensions.resolve("uninstall"))); + } + + @Test + public void uninstallSymlinkQueuesTheLinkWithoutResolvingItsTarget() throws Exception { + Map provider = plugin("provider", "Provider", "2.1.0"); + writeInstalled(provider); + try { + Files.createSymbolicLink(extensions.resolve("link"), extensions.resolve("provider")); + } catch (IOException | UnsupportedOperationException e) { + assumeNoException(e); + } + controller.prepareExtensionForUninstallation("link"); + assertEquals(List.of("link"), Files.readAllLines(extensions.resolve("uninstall"))); + assertEquals(provider.get("provider/plugin.xml"), Files.readString(extensions.resolve("provider/plugin.xml"))); + assertTrue(Files.isSymbolicLink(extensions.resolve("link"))); + } + + @Test + public void uninstallRejectsCaseAliasesAndParentSegmentsBeforeMutation() throws Exception { + writeInstalled(plugin("provider", "Provider", "2.1.0")); + assertActionRejected(() -> controller.prepareExtensionForUninstallation("PROVIDER"), "case"); + assertActionRejected(() -> controller.prepareExtensionForUninstallation("provider/../provider"), "Invalid extension package path"); + assertFalse(Files.exists(extensions.resolve("uninstall"))); + Files.writeString(extensions.resolve("uninstall"), "PROVIDER\n"); + assertRejected(install(plugin("consumer", "Consumer", "1.0.0", "Provider")), "case"); + assertTrue(stagedFiles().isEmpty()); + } + + @Test + public void caseAliasesAndReservedDirectoriesAreRejectedBeforeExtraction() throws Exception { + Map duplicateFile = plugin("provider", "Provider", "2.1.0"); + duplicateFile.put("provider/PLUGIN.XML", plugin("provider", "Other", "2.1.0").get("provider/plugin.xml")); + Map duplicatePackage = plugin("provider", "Provider", "2.1.0"); + duplicatePackage.putAll(plugin("PROVIDER", "Other", "2.1.0")); + for (Map archive : List.of(duplicateFile, duplicatePackage, + plugin("Install_Temp", "Provider", "2.1.0"))) { + assertNotNull("Ambiguous or reserved ZIP path was accepted", install(archive).getCause()); + assertTrue(stagedFiles().isEmpty()); + } + } + + @Test + public void caseOnlyPackageUpdatesCannotOverwriteInstalledOrStagedPackages() throws Exception { + for (boolean staged : new boolean[] { false, true }) { + extensions = temporaryFolder.newFolder().toPath(); + Map original = plugin("provider", "Provider", "2.1.0"); + if (staged) { + assertAccepted(install(original)); + } else { + writeInstalled(original); + } + Map before = stagedFiles(); + // A different metadata identity avoids relying on duplicate provider-name rejection. + assertNotNull(install(plugin("PROVIDER", "Replacement", "2.2.0")).getCause()); + assertEquals(before, stagedFiles()); + Path descriptor = staged ? staged("provider/plugin.xml") : extensions.resolve("provider/plugin.xml"); + assertEquals(original.get("provider/plugin.xml"), Files.readString(descriptor)); + } + } + + @Test + public void duplicateProvidersAndCyclesAreRejectedBeforePayloadExtraction() throws Exception { + Map duplicate = plugin("one", "Provider", "2.1.0"); + duplicate.putAll(plugin("two", "Provider", "2.1.0")); + assertRejected(install(duplicate), "Provider"); + assertTrue(stagedFiles().isEmpty()); + Map cyclic = plugin("alpha", "Alpha", "2.1.0", "Beta"); + cyclic.putAll(plugin("beta", "Beta", "2.1.0", "Alpha")); + assertNotNull(install(cyclic).getCause()); + assertTrue(stagedFiles().isEmpty()); + } + + @Test + public void transitiveFailureRejectsNewConsumerButNotUnrelatedInstallation() throws Exception { + writeInstalled(plugin("provider", "Provider", "2.1.0", "Missing")); + assertRejected(install(plugin("consumer", "Consumer", "1.0.0", "Provider")), "Provider"); + assertFalse(Files.exists(staged("consumer/payload.txt"))); + assertAccepted(install(plugin("independent", "Independent", "1.0.0"))); + } + + @Test + public void malformedDescriptorRejectsTheWholeArchiveAndPreservesStaging() throws Exception { + assertAccepted(install(plugin("provider", "Provider", "2.1.0"))); + Map before = stagedFiles(); + Map broken = plugin("provider", "Provider", "2.2.0"); + broken.put("provider/source.xml", "Incomplete"); + assertNotNull(install(broken).getCause()); + assertEquals(before, stagedFiles()); + } + + @Test + public void incomingDescriptorsMustHaveAnExtensionTypeAndNonblankName() throws Exception { + String valid = plugin("provider", "Provider", "2.1.0").get("provider/plugin.xml"); + for (String invalid : List.of(valid.replace("Provider", ""), + valid.replace("Provider", " "), "", "Unexpected type")) { + Map archive = plugin("provider", "Provider", "2.1.0"); + archive.put("provider/plugin.xml", invalid); + assertNotNull("Invalid metadata identity was accepted", install(archive).getCause()); + assertTrue(stagedFiles().isEmpty()); + } + } + + @Test + public void extractionFailurePreservesPriorStagingAndCorrectedRetryReplacesWholePackage() throws Exception { + Map original = plugin("provider", "Provider", "2.1.0"); + original.put("provider/obsolete.jar", "old library"); + assertAccepted(install(original)); + Map before = stagedFiles(); + Map broken = plugin("provider", "Provider", "2.2.0"); + broken.put("provider/collision", "file blocks directory creation"); + broken.put("provider/collision/child", "cannot extract"); + assertNotNull(install(broken).getCause()); + assertEquals(before, stagedFiles()); + for (int retry = 0; retry < 2; retry++) { + assertAccepted(install(plugin("provider", "Provider", "2.2.0"))); + assertFalse(Files.exists(staged("provider/obsolete.jar"))); + assertEquals(2, stagedFiles().size()); + } + try (var paths = Files.list(extensions)) { + assertFalse("Failed extraction left private staging behind", + paths.anyMatch(path -> path.getFileName().toString().startsWith(".install-"))); + } + } + + @Test + public void failedPackagePromotionRestoresAllPriorStagedPackages() throws Exception { + Map original = plugin("one", "One", "2.1.0"); + original.putAll(plugin("two", "Two", "2.1.0")); + assertAccepted(install(original)); + Map before = stagedFiles(); + Map updated = plugin("one", "One", "2.2.0"); + updated.putAll(plugin("two", "Two", "2.2.0")); + AtomicInteger promotions = new AtomicInteger(); + try (MockedStatic files = mockStatic(Files.class, CALLS_REAL_METHODS)) { + files.when(() -> Files.move(any(Path.class), any(Path.class))).thenAnswer(call -> { + Path source = call.getArgument(0); + if (source.getParent().getFileName().toString().equals("payload") + && promotions.incrementAndGet() == 2) { + throw new IOException("Simulated failure after one package was promoted"); + } + return call.callRealMethod(); + }); + assertRejected(install(updated), "Simulated failure"); + } + assertEquals(2, promotions.get()); + assertEquals(before, stagedFiles()); + assertAccepted(install(updated)); + } + + private static Map plugin(String path, String name, String version, String... providers) { + StringBuilder requirements = new StringBuilder(""); + for (String provider : providers) { + requirements.append(""); + } + Map entries = new LinkedHashMap<>(); + // Place payload before the descriptor to prove rejection precedes extraction. + entries.put(path + "/payload.txt", name); + entries.put(path + "/plugin.xml", "" + name + + "" + version + "" + requirements + + ""); + return entries; + } + + private void writeInstalled(Map entries) throws Exception { + for (Map.Entry entry : entries.entrySet()) { + Path file = extensions.resolve(entry.getKey()); + Files.createDirectories(file.getParent()); + Files.writeString(file, entry.getValue()); + } + } + + private InstallationResult install(Map entries) throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes)) { + Set directories = new HashSet<>(); + for (Map.Entry entry : entries.entrySet()) { + String parent = entry.getKey().substring(0, entry.getKey().lastIndexOf('/') + 1); + if (directories.add(parent)) { + zip.putNextEntry(new ZipEntry(parent)); + zip.closeEntry(); + } + zip.putNextEntry(new ZipEntry(entry.getKey())); + zip.write(entry.getValue().getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + } + return controller.extractExtension(new ByteArrayInputStream(bytes.toByteArray())); + } + + private Path staged(String relative) { + return extensions.resolve("install_temp").resolve(relative); + } + + private Map stagedFiles() throws Exception { + Map contents = new TreeMap<>(); + Path root = staged(""); + if (Files.exists(root)) { + try (var paths = Files.walk(root)) { + for (Path path : (Iterable) paths.filter(Files::isRegularFile)::iterator) { + contents.put(root.relativize(path).toString(), Files.readString(path)); + } + } + } + return contents; + } + + private static void assertAccepted(InstallationResult result) { + assertNull("Installation failed: " + result.getCause(), result.getCause()); + } + + private static void assertRejected(InstallationResult result, String requirement) { + assertNotNull("Installation unexpectedly succeeded", result.getCause()); + assertTrue(result.getCause().toString(), result.getCause().toString().contains(requirement)); + } + + private static void assertActionRejected(ControllerAction action, String requirement) throws Exception { + try { + action.run(); + fail("Administrative action unexpectedly succeeded"); + } catch (ControllerException e) { + assertTrue(e.toString(), e.toString().contains(requirement)); + } + } + + private interface ControllerAction { + void run() throws ControllerException; + } +} diff --git a/server/src/test/java/com/mirth/connect/server/launcher/ExtensionDependenciesTest.java b/server/src/test/java/com/mirth/connect/server/launcher/ExtensionDependenciesTest.java new file mode 100644 index 0000000000..5b6f4b4ae0 --- /dev/null +++ b/server/src/test/java/com/mirth/connect/server/launcher/ExtensionDependenciesTest.java @@ -0,0 +1,297 @@ +/* + * Copyright (c) Open Integration Engine contributors. + * Licensed under the Mozilla Public License 2.0. + */ + +package com.mirth.connect.server.launcher; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; + +import java.io.File; +import java.io.StringReader; +import java.lang.reflect.Field; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.xml.parsers.DocumentBuilderFactory; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.w3c.dom.Element; +import org.xml.sax.InputSource; + +import com.mirth.connect.client.core.ExtensionDependencies; +import com.mirth.connect.client.core.ExtensionDependencies.Extension; +import com.mirth.connect.model.MetaData; +import com.mirth.connect.model.PluginMetaData; +import com.mirth.connect.model.converters.ObjectXMLSerializer; +import com.mirth.connect.server.extprops.LoggerWrapper; + +public class ExtensionDependenciesTest { + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + private Object originalLogger; + + @Before + public void captureLogging() throws Exception { + Field field = MirthLauncher.class.getDeclaredField("logger"); + field.setAccessible(true); + originalLogger = field.get(null); + field.set(null, mock(LoggerWrapper.class)); + } + + @After + public void restoreLogging() throws Exception { + Field field = MirthLauncher.class.getDeclaredField("logger"); + field.setAccessible(true); + field.set(null, originalLogger); + } + + @Test + public void serializedMetadataAndLauncherResolveTheSameGraph() throws Exception { + ObjectXMLSerializer serializer = new ObjectXMLSerializer(getClass().getClassLoader()); + List xml = Arrays.asList( + metadata("pluginMetaData", "Consumer", "1.0.0", engine() + requires("Provider", "1.1.0")), + metadata("pluginMetaData", "Provider", "1.2.0", engine()), + metadata("connectorMetaData", "Connector", "1.0.0", engine() + requires("Consumer", "1.0.0"))); + for (boolean providerEnabled : new boolean[] { true, false }) { + List fromModel = new ArrayList<>(); + List fromLauncher = new ArrayList<>(); + for (String source : xml) { + MetaData metadata = serializer.deserialize(source, MetaData.class); + boolean enabled = providerEnabled || !"Provider".equals(metadata.getName()); + fromModel.add(new Extension(metadata.getName(), metadata instanceof PluginMetaData, + metadata.getPluginVersion(), metadata.getMirthVersion(), metadata.getMinExtensionApiVersion(), + metadata.getDependencies(), enabled)); + fromLauncher.add(MirthLauncher.readExtension(parse(serializer.serialize(metadata)), enabled)); + } + Set expected = providerEnabled ? Collections.emptySet() : new HashSet<>(Arrays.asList("Consumer", "Connector")); + assertEquals(expected, invalidNames(fromModel)); + assertEquals(expected, invalidNames(fromLauncher)); + } + } + + @Test + public void typedEngineRequirementsUseTheSameValidationAsSerializedMetadata() throws Exception { + ObjectXMLSerializer serializer = new ObjectXMLSerializer(getClass().getClassLoader()); + String[] requirements = { + engine(), + "", + "", + "", + "", + engine() + engine(), + "", + "" + }; + for (String requirement : requirements) { + for (String root : new String[] { "pluginMetaData", "connectorMetaData" }) { + String xml = metadata(root, "Example", "1.0.0", requirement); + MetaData model = serializer.deserialize(xml, MetaData.class); + Extension extension = new Extension(model.getName(), model instanceof PluginMetaData, + model.getPluginVersion(), model.getMirthVersion(), model.getMinExtensionApiVersion(), + model.getDependencies(), true); + assertEquals(xml, ExtensionDependencies.getEngineError(extension, "99.0.0") == null, + MirthLauncher.isExtensionCompatible(parse(xml), "99.0.0")); + } + } + assertTrue(MirthLauncher.isExtensionCompatible(parse(metadata("pluginMetaData", "Example", "1.0.0", engine())), "99.0.0")); + assertFalse(MirthLauncher.isExtensionCompatible(parse(metadata("pluginMetaData", "Example", "1.0.0", "")), "99.0.0")); + } + + @Test + public void malformedDeclarationsCannotDisappearDuringBootstrap() throws Exception { + ObjectXMLSerializer serializer = new ObjectXMLSerializer(getClass().getClassLoader()); + String[] declarations = { + "", + "", + "unexpected" + engine(), + "", + "engine-api1.0.0", + "unexpected", + "", + "ignored" + }; + for (String declaration : declarations) { + String xml = metadata("pluginMetaData", "Example", "1.0.0", declaration); + assertFalse(declaration, MirthLauncher.isExtensionCompatible(parse(xml), "4.5.2")); + try { + serializer.deserialize(xml, MetaData.class); + fail("Malformed dependencies must also fail server deserialization: " + declaration); + } catch (com.mirth.connect.donkey.util.xstream.SerializerException expected) { + // Both metadata readers must fail closed before an extension can load. + } + } + assertFalse(MirthLauncher.isExtensionCompatible(parse("4.5.2" + + "" + engine() + ""), "4.5.2")); + String nullList = "4.5.2" + + "" + engine() + ""; + assertNull(serializer.deserialize(nullList, MetaData.class).getDependencies()); + assertTrue(MirthLauncher.isExtensionCompatible(parse(nullList), "4.5.2")); + assertFalse(MirthLauncher.isExtensionCompatible(parse(nullList), "99.0.0")); + } + + @Test + public void metadataOverridesAndDuplicateVersionsCannotBypassPluginValidation() throws Exception { + ObjectXMLSerializer serializer = new ObjectXMLSerializer(getClass().getClassLoader()); + String source = metadata("pluginMetaData", "Provider", "1.0.0", engine()); + String connectorOverride = source.replace("", ""); + assertFalse(serializer.deserialize(connectorOverride, MetaData.class) instanceof PluginMetaData); + assertFalse(MirthLauncher.isExtensionCompatible(parse(connectorOverride), "4.5.2")); + for (String field : new String[] { "name", "pluginVersion", "mirthVersion", "minExtensionApiVersion" }) { + String duplicates = source.replace("", "<" + field + ">1.0.0" + + "<" + field + ">2.0.0"); + assertFalse(field, MirthLauncher.isExtensionCompatible(parse(duplicates), "4.5.2")); + try { + serializer.deserialize(duplicates, MetaData.class); + fail("Duplicate metadata must fail server deserialization: " + field); + } catch (com.mirth.connect.donkey.util.xstream.SerializerException expected) { + // The launcher must not choose one of multiple provider identities or versions. + } + } + } + + @Test + public void classpathResolutionDoesNotDependOnDiscoveryOrderAndFiltersLibraryTypes() throws Exception { + for (boolean consumerFirst : new boolean[] { true, false }) { + File root = temporary.newFolder(); + if (consumerFirst) { + write(root, "consumer", "Consumer", "1.0.0", engine() + requires("Provider", "1.0.0")); + } + write(root, "provider", "Provider", "1.2.0", engine()); + if (!consumerFirst) { + write(root, "consumer", "Consumer", "1.0.0", engine() + requires("Provider", "1.0.0")); + } + assertLibraries(root, Collections.emptySet(), "consumer", "provider"); + } + } + + @Test + public void missingDisabledAndIncompatibleProvidersExcludeAllDependents() throws Exception { + for (String failure : Arrays.asList("missing", "disabled", "plugin-version", "engine-version")) { + File root = temporary.newFolder(); + write(root, "consumer", "Consumer", "1.0.0", engine() + requires("Middle", "1.0.0")); + write(root, "middle", "Middle", "1.0.0", engine() + requires("Provider", "1.0.0")); + write(root, "independent", "Independent", "1.0.0", engine()); + if (!failure.equals("missing")) { + write(root, "provider", "Provider", failure.equals("plugin-version") ? "2.0.0" : "1.0.0", + failure.equals("engine-version") ? "" : engine()); + } + assertLibraries(root, failure.equals("disabled") ? Collections.singleton("Provider") : Collections.emptySet(), + failure.equals("plugin-version") ? new String[] { "independent", "provider" } : new String[] { "independent" }); + } + } + + @Test + public void cyclesAndAmbiguousProvidersExcludeTheirDependents() throws Exception { + File cycle = temporary.newFolder(); + write(cycle, "a", "A", "1.0.0", engine() + requires("B", "1.0.0")); + write(cycle, "b", "B", "1.0.0", engine() + requires("A", "1.0.0")); + write(cycle, "consumer", "Consumer", "1.0.0", engine() + requires("A", "1.0.0")); + write(cycle, "independent", "Independent", "1.0.0", engine()); + assertLibraries(cycle, Collections.emptySet(), "independent"); + + File duplicate = temporary.newFolder(); + write(duplicate, "provider1", "Provider", "1.0.0", engine()); + write(duplicate, "provider2", "Provider", "1.0.0", engine()); + write(duplicate, "consumer", "Consumer", "1.0.0", engine() + requires("Provider", "1.0.0")); + assertLibraries(duplicate, Collections.emptySet()); + } + + @Test + public void connectorsAndUnparseablePluginsCannotSatisfyPluginRequirements() throws Exception { + for (boolean connector : new boolean[] { true, false }) { + File root = temporary.newFolder(); + write(root, "consumer", "Consumer", "1.0.0", engine() + requires("Provider", "1.0.0")); + File provider = new File(root, "provider"); + assertTrue(provider.mkdir()); + String xml = connector ? metadata("connectorMetaData", "Provider", "1.0.0", engine()) + : metadata("pluginMetaData", "Provider", "1.0.0", ""); + writeFiles(provider, connector ? "source.xml" : "plugin.xml", xml); + assertLibraries(root, Collections.emptySet(), connector ? new String[] { "provider" } : new String[0]); + } + } + + @Test + public void malformedDescriptorIdentityDoesNotAffectUnrelatedExtensions() throws Exception { + String[] invalid = { + "", + "not metadata", + metadata("pluginMetaData", "Provider", "1.0.0", engine()).replace("Provider", ""), + metadata("pluginMetaData", " ", "1.0.0", engine()) + }; + for (String descriptor : invalid) { + File root = temporary.newFolder(); + File broken = new File(root, "broken"); + assertTrue(broken.mkdir()); + writeFiles(broken, "plugin.xml", descriptor); + write(root, "independent", "Independent", "1.0.0", engine()); + assertLibraries(root, Collections.emptySet(), "independent"); + } + } + + private Set invalidNames(List extensions) { + return ExtensionDependencies.validate(extensions, "99.0.0").keySet().stream().map(Extension::getName).collect(Collectors.toSet()); + } + + private void assertLibraries(File root, Set disabled, String... names) throws Exception { + List urls = new ArrayList<>(); + MirthLauncher.addExtensionsToClasspath(urls, "4.5.2", root, name -> !disabled.contains(name)); + Set expected = new HashSet<>(); + for (String name : names) { + expected.add(new File(root, name + "/server.jar").toURI().toURL()); + expected.add(new File(root, name + "/shared.jar").toURI().toURL()); + } + assertEquals(expected, new HashSet<>(urls)); + assertEquals("Libraries must not be added more than once", expected.size(), urls.size()); + } + + private void write(File root, String path, String name, String version, String dependencies) throws Exception { + File directory = new File(root, path); + assertTrue(directory.mkdir()); + writeFiles(directory, "plugin.xml", metadata("pluginMetaData", name, version, dependencies)); + } + + private void writeFiles(File directory, String metadataName, String xml) throws Exception { + Files.write(new File(directory, metadataName).toPath(), xml.getBytes(StandardCharsets.UTF_8)); + for (String library : Arrays.asList("server.jar", "shared.jar", "client.jar")) { + Files.write(new File(directory, library).toPath(), new byte[0]); + } + } + + private String metadata(String root, String name, String version, String dependencies) { + return "<" + root + ">" + name + "" + version + "" + + "4.5.2" + dependencies + "" + + "" + + ""; + } + + private String engine() { + return ""; + } + + private String requires(String name, String minimum) { + return ""; + } + + private Element parse(String xml) throws Exception { + return DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(new InputSource(new StringReader(xml))).getDocumentElement(); + } +} From a3894a602861e759f1f6dbb332e3b4a53a84fe6d Mon Sep 17 00:00:00 2001 From: gibson9583 Date: Tue, 22 Sep 2026 07:03:20 -0400 Subject: [PATCH 4/4] refactor: simplify extension compatibility validation Use the engine serializer and recursive dependency validation. Remove redundant API metadata and duplicate launcher compatibility parsing. Extract archive installation into a testable helper while preserving planned-inventory checks and package rollback. Document the single dependency format and startup classpath behavior. Validation: 746 tests, packaged launcher checks, 10,000 graph orderings. Signed-off-by: gibson9583 --- docs/extension-compatibility.md | 25 +- server/build.gradle | 4 - .../client/core/ExtensionCompatibility.java | 8 +- .../client/core/ExtensionDependencies.java | 182 +++++------ .../client/core/ExtensionDependency.java | 7 + .../com/mirth/connect/model/MetaData.java | 10 - .../ExtensionDependenciesConverter.java | 70 ----- .../model/converters/ObjectXMLSerializer.java | 3 +- .../mirth/connect/server/ExtensionLoader.java | 32 +- .../DefaultExtensionController.java | 225 +++---------- .../controllers/ExtensionInstaller.java | 194 ++++++++++++ .../server/launcher/MirthLauncher.java | 190 ++--------- .../core/ExtensionDependenciesTest.java | 158 ++++++---- .../connect/model/ExtensionMetaDataTest.java | 69 ++-- .../connect/server/ExtensionLoaderTest.java | 166 ++++++++++ .../DefaultExtensionControllerTest.java | 65 ++-- .../ExtensionDependencyInstallationTest.java | 194 +++--------- .../controllers/ExtensionInstallerTest.java | 222 +++++++++++++ .../launcher/ExtensionCompatibilityTest.java | 97 ------ .../launcher/ExtensionDependenciesTest.java | 297 ------------------ .../launcher/ExtensionLibrariesTest.java | 115 +++++++ 21 files changed, 1073 insertions(+), 1260 deletions(-) delete mode 100644 server/src/main/java/com/mirth/connect/model/converters/ExtensionDependenciesConverter.java create mode 100644 server/src/main/java/com/mirth/connect/server/controllers/ExtensionInstaller.java create mode 100644 server/src/test/java/com/mirth/connect/server/ExtensionLoaderTest.java create mode 100644 server/src/test/java/com/mirth/connect/server/controllers/ExtensionInstallerTest.java delete mode 100644 server/src/test/java/com/mirth/connect/server/launcher/ExtensionCompatibilityTest.java delete mode 100644 server/src/test/java/com/mirth/connect/server/launcher/ExtensionDependenciesTest.java create mode 100644 server/src/test/java/com/mirth/connect/server/launcher/ExtensionLibrariesTest.java diff --git a/docs/extension-compatibility.md b/docs/extension-compatibility.md index b22553b17c..4354f5a4b2 100644 --- a/docs/extension-compatibility.md +++ b/docs/extension-compatibility.md @@ -169,19 +169,13 @@ check completely. A future API requirement still rejects the extension even if the legacy engine release matches. Every descriptor in the ZIP must pass its own checks or the entire archive is rejected. -If you already use `1.0.0`, it -remains supported. To migrate, remove that element and add the corresponding -`engine-api` dependency. Declaring both is invalid. Plugin dependencies can -also be added while retaining either the shorthand API requirement or legacy -release matching. - ### Supporting older engines Engines predating dependency-list support cannot read `dependencies`. Including legacy fields does **not** make the same archive compatible with those engines. If you need to support them, publish a separately identified legacy archive -that omits `dependencies` and `minExtensionApiVersion` and retains its tested +that omits `dependencies` and retains its tested `mirthVersion` list. Document and verify its plugin prerequisites separately. Do not claim an older release supports API 1.0.0 merely because your extension previously ran on it. To return an archive to legacy matching, remove the API @@ -198,8 +192,7 @@ relative to it. Keep package-directory spelling and case unchanged when updating or uninstalling an extension. Archive paths that differ only by case are rejected to avoid filesystem-dependent results. Do not wrap the extension directory in another `extensions/` directory. Library declarations are direct children of the metadata root, -not nested under a `libraries` element. Include directory entries before their -files, as normal recursive ZIP packaging does: +not nested under a `libraries` element. For example: ```bash zip -r example-extension-1.0.0.zip example/ @@ -239,9 +232,12 @@ or uninstall consumers first when intentionally removing their providers. Restart the server and Administrator to apply staged changes; staging does not unload classes from a running process. -At startup, dependency checks run before extension libraries are admitted and -before metadata is made available to the engine. Unsatisfied extensions are -excluded, with diagnostics identifying the requirement. Disabled consumers +At startup, the engine reads metadata with its normal XML serializer and checks +dependencies before exposing extensions to engine and Administrator hooks. +Unsatisfied extensions are excluded, with diagnostics identifying the requirement. +The launcher discovers libraries for enabled extensions before these checks; +libraries can therefore remain on the shared classpath even when their extension +is rejected. This is an activation check, not classloader isolation. Disabled consumers still need valid declarations and engine compatibility, but their plugin requirements are checked when they are enabled. Compatibility metadata declares a requirement; it does not prove binary or runtime behavior. @@ -268,9 +264,8 @@ not use a name. Unknown types, malformed declarations, duplicate requirements, self-dependencies, cycles, and ambiguous duplicate provider names are rejected. The engine check uses the `engine-api` dependency if present. Otherwise it uses -a non-null `minExtensionApiVersion`, then legacy `mirthVersion` matching when -the shorthand is absent or explicitly null. A list containing only plugin -dependencies, or an empty list, never skips the engine check. Legacy matching +legacy `mirthVersion` matching. A list containing only plugin dependencies, or +an empty list, never skips the engine check. Legacy matching accepts exact comma-separated engine releases, ignoring the engine's fourth build component. Existing descriptors without the new declarations keep this behavior. diff --git a/server/build.gradle b/server/build.gradle index fc5258873c..a8c38cb94c 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -678,10 +678,6 @@ def launcherJar = tasks.register('launcherJar', Jar) { archiveFileName = 'mirth-server-launcher.jar' destinationDirectory = file('setup') from(sourceSets.main.output.classesDirs) { - // The launcher checks compatibility before loading mirth-client-core.jar. - include 'com/mirth/connect/client/core/ExtensionCompatibility.class' - include 'com/mirth/connect/client/core/ExtensionDependency.class' - include 'com/mirth/connect/client/core/ExtensionDependencies*.class' include 'com/mirth/connect/server/launcher/**' include 'com/mirth/connect/server/extprops/**' } diff --git a/server/src/main/java/com/mirth/connect/client/core/ExtensionCompatibility.java b/server/src/main/java/com/mirth/connect/client/core/ExtensionCompatibility.java index 7a06043ad1..baed989259 100644 --- a/server/src/main/java/com/mirth/connect/client/core/ExtensionCompatibility.java +++ b/server/src/main/java/com/mirth/connect/client/core/ExtensionCompatibility.java @@ -7,7 +7,7 @@ import java.util.regex.Pattern; -/** Shared by the launcher and engine; must not depend on engine or third-party classes. */ +/** Numeric extension API and legacy engine release compatibility. */ public final class ExtensionCompatibility { // Independent of the product release. See docs/extension-compatibility.md before changing. public static final String API_VERSION = "1.0.0"; @@ -17,11 +17,11 @@ public final class ExtensionCompatibility { private ExtensionCompatibility() {} - public static boolean isCompatible(String mirthVersions, String minExtensionApiVersion, + public static boolean isCompatible(String mirthVersions, String minimumApiVersion, String serverVersion) { - if (minExtensionApiVersion != null) { + if (minimumApiVersion != null) { // An invalid explicit requirement must never fall back to the legacy release check. - return isApiCompatible(minExtensionApiVersion, API_VERSION); + return isApiCompatible(minimumApiVersion, API_VERSION); } if (mirthVersions == null || serverVersion == null) { diff --git a/server/src/main/java/com/mirth/connect/client/core/ExtensionDependencies.java b/server/src/main/java/com/mirth/connect/client/core/ExtensionDependencies.java index 728e18ad03..0018b4aa41 100644 --- a/server/src/main/java/com/mirth/connect/client/core/ExtensionDependencies.java +++ b/server/src/main/java/com/mirth/connect/client/core/ExtensionDependencies.java @@ -5,7 +5,6 @@ package com.mirth.connect.client.core; -import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -14,69 +13,39 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Queue; import java.util.Set; +import java.util.function.Predicate; -/** Shared dependency validation; must remain usable before engine classes are loaded. */ +import com.mirth.connect.model.MetaData; +import com.mirth.connect.model.PluginMetaData; + +/** Validates engine compatibility and required plugins before extension activation. */ public final class ExtensionDependencies { private ExtensionDependencies() {} - /** One descriptor in the proposed extension inventory. Identity is per descriptor. */ - public static final class Extension { - private final String name; - private final boolean plugin; - private final String pluginVersion; - private final String mirthVersion; - private final String minExtensionApiVersion; - private final List dependencies; - private final boolean enabled; - - public Extension(String name, boolean plugin, String pluginVersion, String mirthVersion, - String minExtensionApiVersion, List dependencies, boolean enabled) { - this.name = name; - this.plugin = plugin; - this.pluginVersion = pluginVersion; - this.mirthVersion = mirthVersion; - this.minExtensionApiVersion = minExtensionApiVersion; - this.dependencies = dependencies == null ? Collections.emptyList() - : Collections.unmodifiableList(new ArrayList<>(dependencies)); - this.enabled = enabled; - } - - public String getName() { - return name; - } - - public boolean isPlugin() { - return plugin; - } - - public boolean isEnabled() { - return enabled; - } - } - /** Returns the first declaration or engine-compatibility error, or null on success. */ - public static String getEngineError(Extension extension, String serverVersion) { - String minimumApiVersion = extension.minExtensionApiVersion; - boolean engineRequirement = false; + public static String getEngineError(MetaData extension, String serverVersion) { + String minimumApiVersion = null; Set pluginRequirements = new HashSet<>(); - for (ExtensionDependency dependency : extension.dependencies) { - if (dependency == null) { + for (Object declaration : dependencies(extension)) { + if (declaration == null) { return "Dependency declaration must not be null."; } + if (!(declaration instanceof ExtensionDependency)) { + return "Dependency declaration must be a dependency element."; + } + ExtensionDependency dependency = (ExtensionDependency) declaration; if (!ExtensionCompatibility.isValidVersion(dependency.getMinVersion())) { return "Dependency minVersion must be a numeric major.minor.patch version."; } if ("engine-api".equals(dependency.getType())) { - if (engineRequirement || extension.minExtensionApiVersion != null) { + if (minimumApiVersion != null) { return "Declare the engine API requirement only once."; } if (dependency.getName() != null) { return "An engine-api dependency must not declare a name."; } - engineRequirement = true; minimumApiVersion = dependency.getMinVersion(); } else if ("plugin".equals(dependency.getType())) { String name = dependency.getName(); @@ -86,7 +55,7 @@ public static String getEngineError(Extension extension, String serverVersion) { if (!pluginRequirements.add(name)) { return "Plugin dependency '" + name + "' is declared more than once."; } - if (extension.plugin && name.equals(extension.name)) { + if (extension instanceof PluginMetaData && name.equals(extension.getName())) { return "A plugin cannot depend on itself: '" + name + "'."; } } else { @@ -94,110 +63,111 @@ public static String getEngineError(Extension extension, String serverVersion) { } } - if (!ExtensionCompatibility.isCompatible(extension.mirthVersion, minimumApiVersion, serverVersion)) { + if (!ExtensionCompatibility.isCompatible(extension.getMirthVersion(), minimumApiVersion, serverVersion)) { return minimumApiVersion == null - ? "The engine release does not match mirthVersion '" + extension.mirthVersion + "'." + ? "The engine release does not match mirthVersion '" + extension.getMirthVersion() + "'." : "Requires engine API '" + minimumApiVersion + "' with the same major version; current API is '" + ExtensionCompatibility.API_VERSION + "'."; } return null; } - /** - * Checks the complete inventory. Disabled descriptors retain declaration/engine checks, but - * only enabled consumers require their plugins to be available. Results follow inventory order. - */ - public static Map validate(Collection extensions, String serverVersion) { - List inventory = new ArrayList<>(extensions); - Map errors = new HashMap<>(); - Map plugins = new HashMap<>(); + /** Disabled consumers retain declaration/engine checks, but do not require their plugins. */ + public static Map validate(Collection extensions, String serverVersion, + Predicate enabled) { + List inventory = new ArrayList<>(extensions); + Map errors = new HashMap<>(); + Map plugins = new HashMap<>(); Set duplicateNames = new HashSet<>(); + Set enabledExtensions = new HashSet<>(); - for (Extension extension : inventory) { + for (MetaData extension : inventory) { String error = getEngineError(extension, serverVersion); if (error != null) { errors.put(extension, error); } - if (extension.plugin && plugins.putIfAbsent(extension.name, extension) != null) { - duplicateNames.add(extension.name); + if (extension instanceof PluginMetaData && plugins.putIfAbsent(extension.getName(), extension) != null) { + duplicateNames.add(extension.getName()); } - } - for (Extension extension : inventory) { - if (extension.plugin && duplicateNames.contains(extension.name)) { - errors.put(extension, "More than one plugin declares the name '" + extension.name + "'."); + if (enabled.test(extension.getName())) { + enabledExtensions.add(extension); } } - Map> consumers = new HashMap<>(); - Map remaining = new HashMap<>(); - for (Extension extension : inventory) { - remaining.put(extension, 0); - if (!extension.enabled || errors.containsKey(extension)) { + // Check direct requirements first, so a missing provider inside a cycle is still reported. + for (MetaData extension : inventory) { + if (extension instanceof PluginMetaData && duplicateNames.contains(extension.getName())) { + errors.put(extension, "More than one plugin declares the name '" + extension.getName() + "'."); + } + if (!enabledExtensions.contains(extension) || errors.containsKey(extension)) { continue; } - for (ExtensionDependency dependency : extension.dependencies) { + for (ExtensionDependency dependency : dependencies(extension)) { if (!"plugin".equals(dependency.getType())) { continue; } String name = dependency.getName(); - Extension provider = plugins.get(name); + MetaData provider = plugins.get(name); String error = null; if (provider == null) { error = "Required plugin '" + name + "' is not installed."; } else if (duplicateNames.contains(name)) { error = "Required plugin name '" + name + "' is ambiguous."; - } else if (!provider.enabled) { + } else if (!enabledExtensions.contains(provider)) { error = "Required plugin '" + name + "' is disabled."; - } else if (!ExtensionCompatibility.isApiCompatible(dependency.getMinVersion(), provider.pluginVersion)) { + } else if (!ExtensionCompatibility.isApiCompatible(dependency.getMinVersion(), provider.getPluginVersion())) { error = "Required plugin '" + name + "' needs version '" + dependency.getMinVersion() - + "' or later with the same major version; installed version is '" + provider.pluginVersion + "'."; + + "' or later with the same major version; installed version is '" + provider.getPluginVersion() + "'."; } if (error != null) { errors.put(extension, error); break; } - consumers.computeIfAbsent(provider, key -> new ArrayList<>()).add(extension); - remaining.put(extension, remaining.get(extension) + 1); } } - // Resolve providers before consumers. Failed providers propagate immediately, including - // into cycles. Any nodes left afterward belong to, or depend on, a dependency cycle. - Queue ready = new ArrayDeque<>(); - Set resolved = new HashSet<>(); - for (Extension extension : inventory) { - if (errors.containsKey(extension) || remaining.get(extension) == 0) { - ready.add(extension); + Set visiting = new HashSet<>(); + Set checked = new HashSet<>(); + for (MetaData extension : inventory) { + if (enabledExtensions.contains(extension)) { + canLoad(extension, plugins, errors, visiting, checked); } } - while (!ready.isEmpty()) { - Extension provider = ready.remove(); - if (!resolved.add(provider)) { - continue; - } - for (Extension consumer : consumers.getOrDefault(provider, Collections.emptyList())) { - if (errors.containsKey(provider) && !errors.containsKey(consumer)) { - errors.put(consumer, "Required plugin '" + provider.name - + "' cannot be loaded; see its compatibility or dependency error."); - ready.add(consumer); - } - int count = remaining.get(consumer) - 1; - remaining.put(consumer, count); - if (count == 0) { - ready.add(consumer); - } - } - } - - Map orderedErrors = new LinkedHashMap<>(); - for (Extension extension : inventory) { - if (!resolved.contains(extension)) { - errors.put(extension, "Circular plugin dependencies prevent loading '" + extension.name + "'."); - } + Map orderedErrors = new LinkedHashMap<>(); + for (MetaData extension : inventory) { if (errors.containsKey(extension)) { orderedErrors.put(extension, errors.get(extension)); } } return orderedErrors; } + + private static boolean canLoad(MetaData extension, Map plugins, + Map errors, Set visiting, Set checked) { + if (errors.containsKey(extension)) { + return false; + } + if (checked.contains(extension)) { + return true; + } + if (!visiting.add(extension)) { + errors.put(extension, "Circular plugin dependencies prevent loading '" + extension.getName() + "'."); + return false; + } + for (ExtensionDependency dependency : dependencies(extension)) { + if ("plugin".equals(dependency.getType()) + && !canLoad(plugins.get(dependency.getName()), plugins, errors, visiting, checked)) { + errors.putIfAbsent(extension, "Required plugin '" + dependency.getName() + + "' cannot be loaded; see its compatibility or dependency error."); + break; + } + } + visiting.remove(extension); + checked.add(extension); + return !errors.containsKey(extension); + } + + private static List dependencies(MetaData extension) { + return extension.getDependencies() == null ? Collections.emptyList() : extension.getDependencies(); + } } diff --git a/server/src/main/java/com/mirth/connect/client/core/ExtensionDependency.java b/server/src/main/java/com/mirth/connect/client/core/ExtensionDependency.java index 8da15789aa..ccdc149152 100644 --- a/server/src/main/java/com/mirth/connect/client/core/ExtensionDependency.java +++ b/server/src/main/java/com/mirth/connect/client/core/ExtensionDependency.java @@ -7,12 +7,19 @@ import java.io.Serializable; +import com.thoughtworks.xstream.annotations.XStreamAlias; +import com.thoughtworks.xstream.annotations.XStreamAsAttribute; + /** A required engine API or plugin version declared by an extension. */ +@XStreamAlias("dependency") public class ExtensionDependency implements Serializable { private static final long serialVersionUID = 1L; + @XStreamAsAttribute private String type; + @XStreamAsAttribute private String name; + @XStreamAsAttribute private String minVersion; public ExtensionDependency() {} diff --git a/server/src/main/java/com/mirth/connect/model/MetaData.java b/server/src/main/java/com/mirth/connect/model/MetaData.java index bd9dc829a6..bfae0e18f2 100644 --- a/server/src/main/java/com/mirth/connect/model/MetaData.java +++ b/server/src/main/java/com/mirth/connect/model/MetaData.java @@ -28,7 +28,6 @@ public abstract class MetaData { private String name; private String author; private String mirthVersion; - private String minExtensionApiVersion; private List dependencies; private String pluginVersion; private String url; @@ -75,15 +74,6 @@ public void setMirthVersion(String mirthVersion) { this.mirthVersion = mirthVersion; } - /** Minimum OIE extension API version; when present, replaces the release version check. */ - public String getMinExtensionApiVersion() { - return minExtensionApiVersion; - } - - public void setMinExtensionApiVersion(String minExtensionApiVersion) { - this.minExtensionApiVersion = minExtensionApiVersion; - } - public List getDependencies() { return dependencies; } diff --git a/server/src/main/java/com/mirth/connect/model/converters/ExtensionDependenciesConverter.java b/server/src/main/java/com/mirth/connect/model/converters/ExtensionDependenciesConverter.java deleted file mode 100644 index 3e55f27970..0000000000 --- a/server/src/main/java/com/mirth/connect/model/converters/ExtensionDependenciesConverter.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (c) Open Integration Engine contributors. - * Licensed under the Mozilla Public License 2.0. - */ - -package com.mirth.connect.model.converters; - -import java.util.ArrayList; -import java.util.List; - -import com.mirth.connect.client.core.ExtensionDependency; -import com.thoughtworks.xstream.converters.ConversionException; -import com.thoughtworks.xstream.converters.Converter; -import com.thoughtworks.xstream.converters.MarshallingContext; -import com.thoughtworks.xstream.converters.UnmarshallingContext; -import com.thoughtworks.xstream.io.HierarchicalStreamReader; -import com.thoughtworks.xstream.io.HierarchicalStreamWriter; - -/** Keeps dependency declarations unambiguous for both XStream and the bootstrap DOM reader. */ -public class ExtensionDependenciesConverter implements Converter { - @Override - public boolean canConvert(Class type) { - return type == null || List.class.isAssignableFrom(type); - } - - @Override - public void marshal(Object value, HierarchicalStreamWriter writer, MarshallingContext context) { - for (ExtensionDependency dependency : (List) value) { - writer.startNode("dependency"); - if (dependency != null) { - writeAttribute(writer, "type", dependency.getType()); - writeAttribute(writer, "name", dependency.getName()); - writeAttribute(writer, "minVersion", dependency.getMinVersion()); - } - writer.endNode(); - } - } - - private void writeAttribute(HierarchicalStreamWriter writer, String name, String value) { - if (value != null) { - writer.addAttribute(name, value); - } - } - - @Override - public Object unmarshal(HierarchicalStreamReader reader, UnmarshallingContext context) { - if (context.getRequiredType() == null) { - return null; - } - List dependencies = new ArrayList<>(); - if (!reader.getValue().trim().isEmpty()) { - throw new ConversionException("Dependencies must contain dependency elements."); - } - while (reader.hasMoreChildren()) { - reader.moveDown(); - if (!"dependency".equals(reader.getNodeName()) || reader.hasMoreChildren() || !reader.getValue().trim().isEmpty()) { - throw new ConversionException("Expected a dependency with type, name and minVersion attributes."); - } - for (int i = 0; i < reader.getAttributeCount(); i++) { - String name = reader.getAttributeName(i); - if (!"type".equals(name) && !"name".equals(name) && !"minVersion".equals(name)) { - throw new ConversionException("Unknown dependency attribute: " + name); - } - } - dependencies.add(new ExtensionDependency(reader.getAttribute("type"), reader.getAttribute("name"), reader.getAttribute("minVersion"))); - reader.moveUp(); - } - return dependencies; - } -} diff --git a/server/src/main/java/com/mirth/connect/model/converters/ObjectXMLSerializer.java b/server/src/main/java/com/mirth/connect/model/converters/ObjectXMLSerializer.java index e80b08efa7..766a2feeb3 100644 --- a/server/src/main/java/com/mirth/connect/model/converters/ObjectXMLSerializer.java +++ b/server/src/main/java/com/mirth/connect/model/converters/ObjectXMLSerializer.java @@ -22,6 +22,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import com.mirth.connect.donkey.model.channel.ConnectorProperties; +import com.mirth.connect.client.core.ExtensionDependency; import com.mirth.connect.donkey.model.channel.DestinationConnectorProperties; import com.mirth.connect.donkey.util.DonkeyElement; import com.mirth.connect.donkey.util.DonkeyElement.DonkeyElementException; @@ -113,6 +114,7 @@ public class ObjectXMLSerializer extends XStreamSerializer { DeployedChannelInfo.class, DriverInfo.class, EventFilter.class, + ExtensionDependency.class, ExtensionLibrary.class, Filter.class, MessageFilter.class, @@ -167,7 +169,6 @@ public ObjectXMLSerializer(int xstreamMode, ClassLoader classLoader) { processAnnotations(annotatedClasses); getXStream().registerConverter(new MapContentConverter(getXStream().getMapper())); getXStream().registerConverter(new PluginMetaDataConverter(getXStream().getMapper())); - getXStream().registerLocalConverter(MetaData.class, "dependencies", new ExtensionDependenciesConverter()); getXStream().registerConverter(new JavaScriptObjectConverter(getXStream().getMapper())); getXStream().registerConverter(new ThrowableConverter(getXStream().getMapper())); getXStream().registerConverter(new FilterTransformerElementsConverter(getXStream().getMapper())); diff --git a/server/src/main/java/com/mirth/connect/server/ExtensionLoader.java b/server/src/main/java/com/mirth/connect/server/ExtensionLoader.java index 2d75db1784..7e780997d8 100644 --- a/server/src/main/java/com/mirth/connect/server/ExtensionLoader.java +++ b/server/src/main/java/com/mirth/connect/server/ExtensionLoader.java @@ -32,7 +32,6 @@ import com.google.inject.Inject; import com.mirth.connect.client.core.ControllerException; import com.mirth.connect.client.core.ExtensionDependencies; -import com.mirth.connect.client.core.ExtensionDependencies.Extension; import com.mirth.connect.client.core.PropertiesConfigurationUtil; import com.mirth.connect.model.ConnectorMetaData; import com.mirth.connect.model.MetaData; @@ -57,10 +56,16 @@ public static ExtensionLoader getInstance() { private Map connectorProtocolsMap = new HashMap(); private Map invalidMetaDataMap = new HashMap(); private boolean loadedExtensions = false; - private ObjectXMLSerializer serializer = ObjectXMLSerializer.getInstance(); + private final ObjectXMLSerializer serializer; private static Logger logger = LogManager.getLogger(ExtensionLoader.class); - private ExtensionLoader() {} + private ExtensionLoader() { + this(ObjectXMLSerializer.getInstance()); + } + + public ExtensionLoader(ObjectXMLSerializer serializer) { + this.serializer = serializer; + } public Map getConnectorMetaData() { loadExtensions(); @@ -145,7 +150,7 @@ public T getControllerInstance(Class abstractClass) { /** Checks declarations and the engine requirement; plugin requirements need the full inventory. */ public boolean isExtensionCompatible(MetaData metaData) { try { - return ExtensionDependencies.getEngineError(describe(metaData, true), getServerVersion()) == null; + return ExtensionDependencies.getEngineError(metaData, getServerVersion()) == null; } catch (Exception e) { logger.error("An error occurred while attempting to determine extension compatibility.", e); return false; @@ -154,19 +159,19 @@ public boolean isExtensionCompatible(MetaData metaData) { /** Validates a complete inventory, including providers rejected by their own requirements. */ public Map getCompatibilityErrors(Collection metadata, Predicate enabled) throws ControllerException { - Map descriptors = new LinkedHashMap<>(); + List candidates = new ArrayList<>(); + Map statuses = new HashMap<>(); Map errors = new LinkedHashMap<>(); for (MetaData extension : metadata) { try { - descriptors.put(describe(extension, enabled.test(extension.getName())), extension); + statuses.computeIfAbsent(extension.getName(), enabled::test); + candidates.add(extension); } catch (Exception e) { - errors.put(extension, "Could not read extension status or metadata: " + e.getMessage()); + errors.put(extension, "Could not read extension status: " + e.getMessage()); } } try { - for (Map.Entry error : ExtensionDependencies.validate(descriptors.keySet(), getServerVersion()).entrySet()) { - errors.put(descriptors.get(error.getKey()), error.getValue()); - } + errors.putAll(ExtensionDependencies.validate(candidates, getServerVersion(), statuses::get)); } catch (Exception e) { logger.error("An error occurred while attempting to determine extension compatibility.", e); throw new ControllerException("Could not determine extension compatibility.", e); @@ -174,11 +179,6 @@ public Map getCompatibilityErrors(Collection metadat return errors; } - private Extension describe(MetaData metadata, boolean enabled) { - return new Extension(metadata.getName(), metadata instanceof PluginMetaData, metadata.getPluginVersion(), - metadata.getMirthVersion(), metadata.getMinExtensionApiVersion(), metadata.getDependencies(), enabled); - } - /** Reads the same package/descriptor layout as the launcher, without including pending installs. */ public Map> readExtensionMetaData(File extensionPath) { Map> packages = new TreeMap<>(); @@ -265,7 +265,7 @@ private String getExtensionsPath() { } } - private String getServerVersion() throws FileNotFoundException, ConfigurationException { + protected String getServerVersion() throws FileNotFoundException, ConfigurationException { PropertiesConfiguration versionConfig = PropertiesConfigurationUtil.create(); InputStream versionPropertiesStream = null; diff --git a/server/src/main/java/com/mirth/connect/server/controllers/DefaultExtensionController.java b/server/src/main/java/com/mirth/connect/server/controllers/DefaultExtensionController.java index 7a784b66a7..502f61e269 100644 --- a/server/src/main/java/com/mirth/connect/server/controllers/DefaultExtensionController.java +++ b/server/src/main/java/com/mirth/connect/server/controllers/DefaultExtensionController.java @@ -9,43 +9,32 @@ package com.mirth.connect.server.controllers; -import java.io.BufferedOutputStream; import java.io.File; -import java.io.FileOutputStream; import java.io.FileWriter; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; import java.io.StringReader; -import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.Enumeration; import java.util.HashMap; -import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.NavigableMap; import java.util.Properties; import java.util.Scanner; import java.util.Set; import java.util.TreeMap; -import java.util.zip.ZipEntry; -import java.util.zip.ZipException; -import java.util.zip.ZipFile; import javax.xml.parsers.DocumentBuilderFactory; import org.apache.commons.io.FileUtils; import org.apache.commons.io.FilenameUtils; -import org.apache.commons.io.IOUtils; import org.apache.commons.io.filefilter.FileFilterUtils; import org.apache.commons.io.filefilter.SuffixFileFilter; import org.apache.commons.lang3.StringUtils; @@ -84,8 +73,9 @@ public class DefaultExtensionController extends ExtensionController { private Logger logger = LogManager.getLogger(this.getClass()); - private ObjectXMLSerializer serializer = ObjectXMLSerializer.getInstance(); - private ConfigurationController configurationController = ControllerFactory.getFactory().createConfigurationController(); + private final ObjectXMLSerializer serializer; + private final ConfigurationController configurationController; + private final File extensionsRoot; // these are plugins for specific extension points, keyed by plugin name // (not path) @@ -105,8 +95,8 @@ public class DefaultExtensionController extends ExtensionController { private Map transmissionModeProviders = new LinkedHashMap(); private MultiFactorAuthenticationPlugin multiFactorAuthenticationPlugin = null; private AuthorizationPlugin authorizationPlugin = null; - private ExtensionLoader extensionLoader = ExtensionLoader.getInstance(); - private ExtensionStatuses extensionStatuses = ExtensionStatuses.getInstance(); + private final ExtensionLoader extensionLoader; + private final ExtensionStatuses extensionStatuses; // singleton pattern private static ExtensionController instance = null; @@ -126,13 +116,23 @@ public static ExtensionController create() { } DefaultExtensionController() { + this(ObjectXMLSerializer.getInstance(), ControllerFactory.getFactory().createConfigurationController(), + ExtensionLoader.getInstance(), ExtensionStatuses.getInstance(), new File(getExtensionsPath())); + } + DefaultExtensionController(ObjectXMLSerializer serializer, ConfigurationController configurationController, + ExtensionLoader extensionLoader, ExtensionStatuses extensionStatuses, File extensionsRoot) { + this.serializer = serializer; + this.configurationController = configurationController; + this.extensionLoader = extensionLoader; + this.extensionStatuses = extensionStatuses; + this.extensionsRoot = extensionsRoot; } @Override public void removePropertiesForUninstalledExtensions() { try { - File uninstallFile = new File(getExtensionsPath(), EXTENSIONS_UNINSTALL_PROPERTIES_FILE); + File uninstallFile = new File(extensionsRoot, EXTENSIONS_UNINSTALL_PROPERTIES_FILE); if (uninstallFile.exists()) { List extensionPaths = FileUtils.readLines(uninstallFile); @@ -454,100 +454,27 @@ public void updatePluginProperties(String name, Properties properties) { @Override public synchronized InstallationResult extractExtension(InputStream inputStream) { - Throwable cause = null; - Set metaDataSet = new LinkedHashSet<>(); - File installTempDir = new File(ExtensionController.getExtensionsPath(), "install_temp"); - File workDir = null; - try { - FileUtils.forceMkdir(installTempDir); - // Keep incomplete extraction outside install_temp, where it cannot become a provider. - workDir = Files.createTempDirectory(installTempDir.getParentFile().toPath(), ".install-").toFile(); - File archive = new File(workDir, "extension.zip"); - try (FileOutputStream output = new FileOutputStream(archive)) { - IOUtils.copy(inputStream, output); - } - File payload = new File(workDir, "payload"); - FileUtils.forceMkdir(payload); - Map> incoming = new LinkedHashMap<>(); - try (ZipFile zipFile = new ZipFile(archive)) { - Enumeration entries = zipFile.entries(); - Set entryNames = new HashSet<>(); - Map packageNames = new HashMap<>(); - while (entries.hasMoreElements()) { - ZipEntry entry = entries.nextElement(); - String name = entry.getName(); - String[] parts = name.split("/"); - if (!entryNames.add(name.toLowerCase(Locale.ROOT)) || parts.length == 0 || parts[0].isEmpty() - || name.contains("\\") || name.startsWith("/") - || isReservedExtensionPath(parts[0]) - || (!entry.isDirectory() && parts.length < 2)) { - throw new ZipException("Invalid extension archive entry: " + name); - } - for (String part : parts) { - if (part.equals(".") || part.equals("..") || part.isEmpty()) { - throw new ZipException("Invalid extension archive entry: " + name); - } - } - String previousName = packageNames.putIfAbsent(parts[0].toLowerCase(Locale.ROOT), parts[0]); - if (previousName != null && !previousName.equals(parts[0])) { - throw new ZipException("Package paths must not differ only by case: " + parts[0]); - } - List metadata = incoming.computeIfAbsent(parts[0], key -> new ArrayList<>()); - if (!entry.isDirectory() && ExtensionLoader.isMetaDataFile(parts[parts.length - 1])) { - if (parts.length != 2) { - throw new ZipException("Extension metadata must be directly inside its package: " + name); - } - try (InputStream metadataStream = zipFile.getInputStream(entry)) { - MetaData extension = serializer.deserialize(IOUtils.toString(metadataStream), MetaData.class); - if (!(extension instanceof PluginMetaData || extension instanceof ConnectorMetaData) - || StringUtils.isBlank(extension.getName())) { - throw new ZipException("Expected named plugin or connector metadata: " + name); - } - if (!parts[0].equals(extension.getPath())) { - throw new ZipException("Metadata path must match its package directory: " + name); - } - metadata.add(extension); - metaDataSet.add(extension); - } + ExtensionInstaller.Result result = new ExtensionInstaller(extensionsRoot, serializer).install(inputStream, incoming -> { + Map> before = getPlannedExtensions(); + for (String path : incoming.keySet()) { + for (String installedPath : before.keySet()) { + if (path.equalsIgnoreCase(installedPath) && !path.equals(installedPath)) { + throw new ControllerException("Package path must retain its installed case: " + installedPath); } } - if (metaDataSet.isEmpty()) { - throw new ZipException("Extension archive contains no extension metadata."); - } - Map> before = getPlannedExtensions(); - Map> after = new LinkedHashMap<>(before); - for (String path : incoming.keySet()) { - for (String installedPath : before.keySet()) { - if (path.equalsIgnoreCase(installedPath) && !path.equals(installedPath)) { - throw new ZipException("Package path must retain its installed case: " + installedPath); - } - } - } - after.putAll(incoming); - String error = getExtensionChangeError(before, after, metaDataSet, null, false); - if (error != null) { - throw new VersionMismatchException(error); - } - - entries = zipFile.entries(); - while (entries.hasMoreElements()) { - extractZipEntry(entries.nextElement(), payload, zipFile); - } } - stageExtensionPackages(payload, installTempDir, workDir); - } catch (Throwable t) { - cause = t instanceof ControllerException || t instanceof VersionMismatchException ? t : new ControllerException("Error extracting extension. " + t, t); - } finally { - // A failed rollback keeps its backups for recovery instead of deleting the last copy. - if (workDir != null && !new File(workDir, "backup").exists()) { - FileUtils.deleteQuietly(workDir); + Map> after = new LinkedHashMap<>(before); + after.putAll(incoming); + String error = getExtensionChangeError(before, after, flatten(incoming), null, false); + if (error != null) { + throw new VersionMismatchException(error); } - } - return new InstallationResult(cause, metaDataSet); + }); + return new InstallationResult(result.cause(), result.metadata()); } private Map> getPlannedExtensions() throws ControllerException { - File root = new File(ExtensionController.getExtensionsPath()); + File root = extensionsRoot; Map> inventory = extensionLoader.readExtensionMetaData(root); File uninstall = new File(root, EXTENSIONS_UNINSTALL_FILE); try { @@ -564,13 +491,6 @@ private Map> getPlannedExtensions() throws ControllerExce } } - private boolean isReservedExtensionPath(String path) { - path = path.toLowerCase(Locale.ROOT); - return path.equals("install_temp") || path.startsWith(".install-") - || path.equals(EXTENSIONS_UNINSTALL_FILE) || path.equals(EXTENSIONS_UNINSTALL_PROPERTIES_FILE.toLowerCase(Locale.ROOT)) - || path.equals(EXTENSIONS_UNINSTALL_SCRIPTS_FILE.toLowerCase(Locale.ROOT)); - } - private String normalizeExtensionPath(String path) throws ControllerException { if (path == null || path.isEmpty() || path.contains("\\")) { throw new ControllerException("A valid extension package path is required."); @@ -584,11 +504,11 @@ private String normalizeExtensionPath(String path) throws ControllerException { } relative = relative.normalize(); if (relative.isAbsolute() || relative.getNameCount() != 1 || relative.toString().isEmpty() - || isReservedExtensionPath(relative.toString())) { + || ExtensionInstaller.isReservedPath(relative.toString())) { throw new ControllerException("Invalid extension package path: " + path); } String name = relative.toString(); - File root = new File(ExtensionController.getExtensionsPath()); + File root = extensionsRoot; for (File directory : new File[] { root, new File(root, "install_temp") }) { File[] packages = directory.listFiles(File::isDirectory); if (packages != null) { @@ -643,40 +563,6 @@ private String getExtensionChangeError(Map> before, Map staged = new ArrayList<>(); - try { - for (File source : payload.listFiles()) { - File target = new File(installTempDir, source.getName()); - if (target.exists()) { - Files.move(target.toPath(), new File(backup, source.getName()).toPath()); - } - Files.move(source.toPath(), target.toPath()); - staged.add(target); - } - } catch (IOException e) { - try { - for (File target : staged) { - FileUtils.deleteDirectory(target); - } - for (File original : backup.listFiles()) { - Files.move(original.toPath(), new File(installTempDir, original.getName()).toPath()); - } - FileUtils.deleteDirectory(backup); - } catch (IOException rollback) { - e.addSuppressed(rollback); - throw new IOException("Could not restore pending extensions; recovery files retained at " + backup, e); - } - throw e; - } - if (!FileUtils.deleteQuietly(backup)) { - logger.warn("Extensions staged successfully, but old staging files could not be removed: {}", backup); - } - } - /** * Adds the specified plugin path to a list of plugins that should be deleted on next server * startup. Also deletes the schema version property from the database. If this function fails @@ -689,7 +575,7 @@ public synchronized void prepareExtensionForUninstallation(String pluginPath) th pluginPath = normalizeExtensionPath(pluginPath); Map> before = getPlannedExtensions(); Map> after = new LinkedHashMap<>(before); - if (!new File(new File(ExtensionController.getExtensionsPath(), "install_temp"), pluginPath).isDirectory()) { + if (!new File(new File(extensionsRoot, "install_temp"), pluginPath).isDirectory()) { after.remove(pluginPath); } String error = getExtensionChangeError(before, after, Collections.emptyList(), null, false); @@ -749,7 +635,7 @@ private List parseUninstallScript(String script) { * by MirthLauncher */ private void addExtensionToUninstallFile(String pluginPath) { - File uninstallFile = new File(getExtensionsPath(), EXTENSIONS_UNINSTALL_FILE); + File uninstallFile = new File(extensionsRoot, EXTENSIONS_UNINSTALL_FILE); FileWriter writer = null; try { @@ -763,7 +649,7 @@ private void addExtensionToUninstallFile(String pluginPath) { } private void addExtensionToUninstallPropertiesFile(String pluginName) { - File uninstallFile = new File(getExtensionsPath(), EXTENSIONS_UNINSTALL_PROPERTIES_FILE); + File uninstallFile = new File(extensionsRoot, EXTENSIONS_UNINSTALL_PROPERTIES_FILE); FileWriter writer = null; try { @@ -856,14 +742,14 @@ public void uninstallExtensions() { } // delete the uninstall scripts file - FileUtils.deleteQuietly(new File(getExtensionsPath(), EXTENSIONS_UNINSTALL_SCRIPTS_FILE)); + FileUtils.deleteQuietly(new File(extensionsRoot, EXTENSIONS_UNINSTALL_SCRIPTS_FILE)); } private void appendToUninstallScript(List uninstallStatements) throws IOException { if (uninstallStatements != null) { List uninstallScripts = readUninstallScript(); uninstallScripts.addAll(uninstallStatements); - File uninstallScriptsFile = new File(getExtensionsPath(), EXTENSIONS_UNINSTALL_SCRIPTS_FILE); + File uninstallScriptsFile = new File(extensionsRoot, EXTENSIONS_UNINSTALL_SCRIPTS_FILE); FileUtils.writeStringToFile(uninstallScriptsFile, serializer.serialize(uninstallScripts)); } } @@ -873,7 +759,7 @@ private void appendToUninstallScript(List uninstallStatements) throws IO */ @SuppressWarnings("unchecked") private List readUninstallScript() throws IOException { - File uninstallScriptsFile = new File(getExtensionsPath(), EXTENSIONS_UNINSTALL_SCRIPTS_FILE); + File uninstallScriptsFile = new File(extensionsRoot, EXTENSIONS_UNINSTALL_SCRIPTS_FILE); List scripts = new ArrayList(); if (uninstallScriptsFile.exists()) { @@ -908,39 +794,4 @@ public List getServerPlugins() { // Copied into a List so the ExtensionController signature stays unchanged for extensions. return new ArrayList(serverPlugins); } - - void extractZipEntry(ZipEntry entry, File installTempDir, ZipFile zipFile) throws IOException { - String canonicalDestinationDirPath = installTempDir.getCanonicalPath(); - File destinationfile = new File(installTempDir, entry.getName()); - String canonicalDestinationFile = destinationfile.getCanonicalPath(); - - if (!canonicalDestinationFile.startsWith(canonicalDestinationDirPath + File.separator)) { - throw new ZipException("Zip file is attempting to traverse out of base directory"); - } - - if (entry.isDirectory()) { - /* - * assume directories are stored parents first then children. - * - * TODO: this is not robust, just for demonstration purposes. - */ - File directory = new File(installTempDir, entry.getName()); - directory.mkdir(); - } else { - // otherwise, write the file out to the install temp dir - InputStream zipInputStream = null; - FileOutputStream fileOutputStream = null; - OutputStream outputStream = null; - try { - zipInputStream = zipFile.getInputStream(entry); - fileOutputStream = new FileOutputStream(new File(installTempDir, entry.getName())); - outputStream = new BufferedOutputStream(fileOutputStream); - IOUtils.copy(zipInputStream, outputStream); - } finally { - ResourceUtil.closeResourceQuietly(outputStream); - ResourceUtil.closeResourceQuietly(fileOutputStream); - ResourceUtil.closeResourceQuietly(zipInputStream); - } - } - } } diff --git a/server/src/main/java/com/mirth/connect/server/controllers/ExtensionInstaller.java b/server/src/main/java/com/mirth/connect/server/controllers/ExtensionInstaller.java new file mode 100644 index 0000000000..9d1bb1e88a --- /dev/null +++ b/server/src/main/java/com/mirth/connect/server/controllers/ExtensionInstaller.java @@ -0,0 +1,194 @@ +/* + * Copyright (c) Open Integration Engine contributors. + * Licensed under the Mozilla Public License 2.0. + */ + +package com.mirth.connect.server.controllers; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipException; +import java.util.zip.ZipFile; + +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; + +import com.mirth.connect.client.core.ControllerException; +import com.mirth.connect.client.core.VersionMismatchException; +import com.mirth.connect.model.ConnectorMetaData; +import com.mirth.connect.model.MetaData; +import com.mirth.connect.model.PluginMetaData; +import com.mirth.connect.model.converters.ObjectXMLSerializer; +import com.mirth.connect.server.ExtensionLoader; + +/** Reads and stages complete packages only after their proposed inventory has been validated. */ +final class ExtensionInstaller { + private final File root; + private final ObjectXMLSerializer serializer; + + ExtensionInstaller(File root, ObjectXMLSerializer serializer) { + this.root = root; + this.serializer = serializer; + } + + @FunctionalInterface + interface Validator { + void validate(Map> incoming) throws ControllerException, VersionMismatchException; + } + + record Result(Throwable cause, Set metadata) {} + + Result install(InputStream input, Validator validator) { + Set metadata = new LinkedHashSet<>(); + File work = null; + Throwable cause = null; + try { + File staging = new File(root, "install_temp"); + FileUtils.forceMkdir(staging); + // Unvalidated payloads must not appear in the pending installation inventory. + work = Files.createTempDirectory(root.toPath(), ".install-").toFile(); + File archive = new File(work, "extension.zip"); + Files.copy(input, archive.toPath()); + File payload = new File(work, "payload"); + FileUtils.forceMkdir(payload); + try (ZipFile zip = new ZipFile(archive)) { + Map> incoming = readArchive(zip, metadata); + if (metadata.isEmpty()) { + throw new ZipException("Extension archive contains no extension metadata."); + } + validator.validate(incoming); + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + extractZipEntry(entries.nextElement(), payload, zip); + } + } + stagePackages(payload, staging, work); + } catch (Exception e) { + cause = e instanceof ControllerException || e instanceof VersionMismatchException ? e + : new ControllerException("Error extracting extension. " + e, e); + } finally { + // Preserve the last copy if rollback failed; the exception reports its location. + if (work != null && !new File(work, "backup").exists()) { + FileUtils.deleteQuietly(work); + } + } + return new Result(cause, metadata); + } + + private Map> readArchive(ZipFile zip, Set parsed) throws IOException { + Map> incoming = new LinkedHashMap<>(); + Set entryNames = new HashSet<>(); + Map packageNames = new HashMap<>(); + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + String name = entry.getName(); + String[] parts = name.split("/"); + if (!entryNames.add(name.toLowerCase(Locale.ROOT)) || parts.length == 0 || parts[0].isEmpty() + || name.contains("\\") || name.startsWith("/") || isReservedPath(parts[0]) + || (!entry.isDirectory() && parts.length < 2)) { + throw new ZipException("Invalid extension archive entry: " + name); + } + for (String part : parts) { + if (part.equals(".") || part.equals("..") || part.isEmpty()) { + throw new ZipException("Invalid extension archive entry: " + name); + } + } + String previous = packageNames.putIfAbsent(parts[0].toLowerCase(Locale.ROOT), parts[0]); + if (previous != null && !previous.equals(parts[0])) { + throw new ZipException("Package paths must not differ only by case: " + parts[0]); + } + List metadata = incoming.computeIfAbsent(parts[0], key -> new ArrayList<>()); + if (!entry.isDirectory() && ExtensionLoader.isMetaDataFile(parts[parts.length - 1])) { + if (parts.length != 2) { + throw new ZipException("Extension metadata must be directly inside its package: " + name); + } + try (InputStream input = zip.getInputStream(entry)) { + MetaData extension = serializer.deserialize(IOUtils.toString(input), MetaData.class); + if (!(extension instanceof PluginMetaData || extension instanceof ConnectorMetaData) + || StringUtils.isBlank(extension.getName())) { + throw new ZipException("Expected named plugin or connector metadata: " + name); + } + if (!parts[0].equals(extension.getPath())) { + throw new ZipException("Metadata path must match its package directory: " + name); + } + metadata.add(extension); + parsed.add(extension); + } + } + } + return incoming; + } + + static boolean isReservedPath(String path) { + path = path.toLowerCase(Locale.ROOT); + return path.equals("install_temp") || path.startsWith(".install-") + || path.equals(ExtensionController.EXTENSIONS_UNINSTALL_FILE) + || path.equals(ExtensionController.EXTENSIONS_UNINSTALL_PROPERTIES_FILE.toLowerCase(Locale.ROOT)) + || path.equals(ExtensionController.EXTENSIONS_UNINSTALL_SCRIPTS_FILE.toLowerCase(Locale.ROOT)); + } + + static void extractZipEntry(ZipEntry entry, File destination, ZipFile zip) throws IOException { + File file = new File(destination, entry.getName()); + if (!file.getCanonicalPath().startsWith(destination.getCanonicalPath() + File.separator)) { + throw new ZipException("Zip file is attempting to traverse out of base directory"); + } + if (entry.isDirectory()) { + FileUtils.forceMkdir(file); + } else { + FileUtils.forceMkdirParent(file); + try (InputStream input = zip.getInputStream(entry)) { + Files.copy(input, file.toPath()); + } + } + } + + /** Replace whole packages so retries cannot retain descriptors from an older archive. */ + private void stagePackages(File payload, File staging, File work) throws IOException { + File backup = new File(work, "backup"); + FileUtils.forceMkdir(backup); + List staged = new ArrayList<>(); + try { + for (File source : payload.listFiles()) { + File target = new File(staging, source.getName()); + if (target.exists()) { + Files.move(target.toPath(), new File(backup, source.getName()).toPath()); + } + Files.move(source.toPath(), target.toPath()); + staged.add(target); + } + } catch (IOException e) { + try { + for (File target : staged) { + FileUtils.deleteDirectory(target); + } + for (File original : backup.listFiles()) { + Files.move(original.toPath(), new File(staging, original.getName()).toPath()); + } + FileUtils.deleteDirectory(backup); + } catch (IOException rollback) { + e.addSuppressed(rollback); + throw new IOException("Could not restore pending extensions; recovery files retained at " + backup, e); + } + throw e; + } + if (!FileUtils.deleteQuietly(backup)) { + LogManager.getLogger(ExtensionInstaller.class).warn("Extensions staged successfully, but old staging files could not be removed: {}", backup); + } + } +} diff --git a/server/src/main/java/com/mirth/connect/server/launcher/MirthLauncher.java b/server/src/main/java/com/mirth/connect/server/launcher/MirthLauncher.java index 434f972dcd..772148f2c1 100644 --- a/server/src/main/java/com/mirth/connect/server/launcher/MirthLauncher.java +++ b/server/src/main/java/com/mirth/connect/server/launcher/MirthLauncher.java @@ -17,12 +17,9 @@ import java.net.URLClassLoader; import java.util.ArrayList; import java.util.Collection; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; -import java.util.function.Predicate; import java.util.Properties; -import java.util.jar.JarFile; +import java.util.function.Predicate; import javax.xml.parsers.DocumentBuilderFactory; @@ -36,9 +33,6 @@ import org.w3c.dom.Element; import org.w3c.dom.Node; -import com.mirth.connect.client.core.ExtensionDependencies; -import com.mirth.connect.client.core.ExtensionDependencies.Extension; -import com.mirth.connect.client.core.ExtensionDependency; import com.mirth.connect.server.extprops.ExtensionStatuses; import com.mirth.connect.server.extprops.LoggerWrapper; @@ -55,7 +49,6 @@ public class MirthLauncher { private static LoggerWrapper logger; public static void main(String[] args) { - JarFile mirthClientCoreJarFile = null; try { Log4jMigrations.migrateConfiguration(new File(LOG4J_PROPERTIES_FILE)); @@ -106,14 +99,8 @@ public static void main(String[] args) { ManifestEntry[] manifest = manifestList.toArray(new ManifestEntry[manifestList.size()]); - // Get the current server version - mirthClientCoreJarFile = new JarFile(mirthClientCoreJar.getName()); - Properties versionProperties = new Properties(); - versionProperties.load(mirthClientCoreJarFile.getInputStream(mirthClientCoreJarFile.getJarEntry("version.properties"))); - String currentVersion = versionProperties.getProperty("mirth.version"); - addManifestToClasspath(manifest, classpathUrls); - addExtensionsToClasspath(classpathUrls, currentVersion); + addExtensionsToClasspath(classpathUrls, new File(EXTENSIONS_DIR), ExtensionStatuses.getInstance()::isEnabled); URLClassLoader classLoader = new URLClassLoader(classpathUrls.toArray(new URL[classpathUrls.size()]), Thread.currentThread().getContextClassLoader()); Class mirthClass = classLoader.loadClass("com.mirth.connect.server.Mirth"); Thread mirthThread = (Thread) mirthClass.newInstance(); @@ -121,14 +108,6 @@ public static void main(String[] args) { mirthThread.start(); } catch (Exception e) { e.printStackTrace(); - } finally { - try { - if (mirthClientCoreJarFile != null) { - mirthClientCoreJarFile.close(); - } - } catch (IOException e) { - logger.error("Error closing mirthClientCoreJarFile.", e); - } } } @@ -234,23 +213,15 @@ private static void addManifestToClasspath(ManifestEntry[] manifestEntries, List } } - private static void addExtensionsToClasspath(List urls, String currentVersion) throws Exception { - addExtensionsToClasspath(urls, currentVersion, new File(EXTENSIONS_DIR), ExtensionStatuses.getInstance()::isEnabled); - } - - static void addExtensionsToClasspath(List urls, String currentVersion, File extensionPath, - Predicate enabled) throws Exception { + // Compatibility is checked by ExtensionLoader using the engine's metadata serializer. + static void addExtensionsToClasspath(List urls, File extensionPath, Predicate enabled) { FileFilter extensionFileFilter = new NameFileFilter(new String[] { "plugin.xml", "source.xml", "destination.xml" }, IOCase.INSENSITIVE); - File[] directories = extensionPath.listFiles((FileFilter) FileFilterUtils.directoryFileFilter()); + File[] directories = extensionPath.listFiles(File::isDirectory); if (directories == null) { logger.warn("no extensions found"); return; } - - // Resolve the complete inventory before adding libraries, regardless of filesystem order. - Map metadata = new LinkedHashMap<>(); - Map paths = new LinkedHashMap<>(); for (File directory : directories) { if ("install_temp".equals(directory.getName()) || directory.getName().startsWith(".install-")) { continue; @@ -265,146 +236,43 @@ static void addExtensionsToClasspath(List urls, String currentVersion, File dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); Document document = dbf.newDocumentBuilder().parse(extensionFile); Element root = document.getDocumentElement(); - String name = getMetadataValue(root, "name"); + String name = getExtensionName(root); if (name == null || name.trim().isEmpty()) { throw new IllegalArgumentException("Extension metadata must declare a name"); } - Extension extension = readExtension(root, enabled.test(name)); - metadata.put(extension, root); - paths.put(extension, directory); - } catch (Exception e) { - logger.error("failed to parse extension metadata: " + extensionFile.getAbsolutePath(), e); - } - } - } - - Map errors = ExtensionDependencies.validate(metadata.keySet(), currentVersion); - for (Map.Entry entry : metadata.entrySet()) { - Extension extension = entry.getKey(); - if (errors.containsKey(extension)) { - logger.error("could not load extension " + extension.getName() + ": " + errors.get(extension)); - continue; - } - if (!extension.isEnabled()) { - continue; - } - for (Node child = entry.getValue().getFirstChild(); child != null; child = child.getNextSibling()) { - if (!(child instanceof Element) || !"library".equals(child.getNodeName())) { - continue; - } - Element library = (Element) child; - String type = library.getAttribute("type"); - if (type.equalsIgnoreCase("server") || type.equalsIgnoreCase("shared")) { - File pathFile = new File(paths.get(extension), library.getAttribute("path")); - if (pathFile.exists()) { - logger.trace("adding library to classpath: " + pathFile.getAbsolutePath()); - urls.add(pathFile.toURI().toURL()); - } else { - logger.error("could not locate library: " + pathFile.getAbsolutePath()); + if (!enabled.test(name)) { + continue; } - } - } - } - } - - static boolean isExtensionCompatible(Element metadata, String currentVersion) { - try { - return ExtensionDependencies.getEngineError(readExtension(metadata, true), currentVersion) == null; - } catch (IllegalArgumentException e) { - return false; - } - } - - static Extension readExtension(Element metadata, boolean enabled) { - String root = metadata.getNodeName(); - if (!"pluginMetaData".equals(root) && !"connectorMetaData".equals(root)) { - throw new IllegalArgumentException("Unknown extension metadata type: " + root); - } - String metadataType = metadata.hasAttribute("resolves-to") ? metadata.getAttribute("resolves-to") : metadata.getAttribute("class"); - String className = "com.mirth.connect.model." + ("pluginMetaData".equals(root) ? "PluginMetaData" : "ConnectorMetaData"); - if (!metadataType.isEmpty() && !root.equals(metadataType) && !className.equals(metadataType)) { - throw new IllegalArgumentException("Extension metadata type must match its root element"); - } - List dependencies = new ArrayList<>(); - boolean foundDependencies = false; - for (Node child = metadata.getFirstChild(); child != null; child = child.getNextSibling()) { - if (!(child instanceof Element) || !"dependencies".equals(child.getNodeName())) { - continue; - } - if (foundDependencies) { - throw new IllegalArgumentException("Duplicate dependencies list"); - } - foundDependencies = true; - Element list = (Element) child; - if (isNull(list)) { - continue; - } - for (Node item = list.getFirstChild(); item != null; item = item.getNextSibling()) { - if (!(item instanceof Element)) { - if ((item.getNodeType() == Node.TEXT_NODE || item.getNodeType() == Node.CDATA_SECTION_NODE) - && !item.getNodeValue().trim().isEmpty()) { - throw new IllegalArgumentException("Dependencies must contain dependency elements"); - } - continue; - } - Element dependency = (Element) item; - if (!"dependency".equals(dependency.getNodeName())) { - throw new IllegalArgumentException("Unknown dependency element: " + dependency.getNodeName()); - } - for (int i = 0; i < dependency.getAttributes().getLength(); i++) { - String name = dependency.getAttributes().item(i).getNodeName(); - if (!"type".equals(name) && !"name".equals(name) && !"minVersion".equals(name)) { - throw new IllegalArgumentException("Unknown dependency attribute: " + name); - } - } - for (Node value = dependency.getFirstChild(); value != null; value = value.getNextSibling()) { - if (value instanceof Element || ((value.getNodeType() == Node.TEXT_NODE - || value.getNodeType() == Node.CDATA_SECTION_NODE) && !value.getNodeValue().trim().isEmpty())) { - throw new IllegalArgumentException("Dependency values must be attributes"); + for (Node child = root.getFirstChild(); child != null; child = child.getNextSibling()) { + if (!(child instanceof Element) || !"library".equals(child.getNodeName())) { + continue; + } + Element library = (Element) child; + String type = library.getAttribute("type"); + if (type.equalsIgnoreCase("server") || type.equalsIgnoreCase("shared")) { + File pathFile = new File(directory, library.getAttribute("path")); + if (pathFile.exists()) { + logger.trace("adding library to classpath: " + pathFile.getAbsolutePath()); + urls.add(pathFile.toURI().toURL()); + } else { + logger.error("could not locate library: " + pathFile.getAbsolutePath()); + } + } } + } catch (Exception e) { + logger.error("failed to parse extension metadata: " + extensionFile.getAbsolutePath(), e); } - dependencies.add(new ExtensionDependency(attribute(dependency, "type"), - attribute(dependency, "name"), attribute(dependency, "minVersion"))); } } - return new Extension(getMetadataValue(metadata, "name"), "pluginMetaData".equals(root), - getMetadataValue(metadata, "pluginVersion"), getMetadataValue(metadata, "mirthVersion"), - getMetadataValue(metadata, "minExtensionApiVersion"), dependencies, enabled); - } - - private static String attribute(Element element, String name) { - return element.hasAttribute(name) ? element.getAttribute(name) : null; } - private static boolean isNull(Element element) { - String type = element.hasAttribute("resolves-to") ? element.getAttribute("resolves-to") : element.getAttribute("class"); - return "null".equals(type) || "com.thoughtworks.xstream.mapper.Mapper$Null".equals(type); - } - - private static String getMetadataValue(Element metadata, String name) { - String result = null; - boolean found = false; + private static String getExtensionName(Element metadata) { for (Node child = metadata.getFirstChild(); child != null; child = child.getNextSibling()) { - if (child instanceof Element && name.equals(child.getNodeName())) { - if (found) { - throw new IllegalArgumentException("Duplicate metadata field: " + name); - } - found = true; - // Match XStream's null and scalar text handling without loading XStream here. - Element element = (Element) child; - if (isNull(element)) { - continue; - } - StringBuilder text = new StringBuilder(); - for (Node value = child.getFirstChild(); value != null; value = value.getNextSibling()) { - if (value.getNodeType() == Node.TEXT_NODE || value.getNodeType() == Node.CDATA_SECTION_NODE) { - text.append(value.getNodeValue()); - } - } - result = text.toString(); + if (child instanceof Element && "name".equals(child.getNodeName())) { + return child.getTextContent(); } } - return result; + return null; } private static void createAppdataDir(Properties mirthProperties) { diff --git a/server/src/test/java/com/mirth/connect/client/core/ExtensionDependenciesTest.java b/server/src/test/java/com/mirth/connect/client/core/ExtensionDependenciesTest.java index c7c56ba94c..682971290e 100644 --- a/server/src/test/java/com/mirth/connect/client/core/ExtensionDependenciesTest.java +++ b/server/src/test/java/com/mirth/connect/client/core/ExtensionDependenciesTest.java @@ -12,19 +12,25 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import org.junit.Test; -import com.mirth.connect.client.core.ExtensionDependencies.Extension; +import com.mirth.connect.model.ConnectorMetaData; +import com.mirth.connect.model.MetaData; +import com.mirth.connect.model.PluginMetaData; public class ExtensionDependenciesTest { private static final String SERVER_VERSION = "4.5.2"; + private final Set disabledNames = new HashSet<>(); @Test public void engineRequirementUsesIndependentApiVersion() { - Extension extension = descriptor("Consumer", true, "1.0.0", "old release", null, true, + MetaData extension = descriptor("Consumer", true, "1.0.0", "old release", null, true, requirement("engine-api", null, "1.0.0")); assertNull(ExtensionDependencies.getEngineError(extension, "99.0.0")); assertError(descriptor("Consumer", true, "1.0.0", SERVER_VERSION, null, true, @@ -36,15 +42,15 @@ public void engineRequirementUsesIndependentApiVersion() { @Test public void emptyAndPluginOnlyDependenciesPreserveLegacyEngineGate() { - Extension provider = plugin("Provider"); - Extension empty = descriptor("Empty", true, "1.0.0", "old release", null, true); - Extension consumer = descriptor("Consumer", true, "1.0.0", "old release", null, true, + MetaData provider = plugin("Provider"); + MetaData empty = descriptor("Empty", true, "1.0.0", "old release", null, true); + MetaData consumer = descriptor("Consumer", true, "1.0.0", "old release", null, true, pluginRequirement("Provider", "1.0.0")); - Map errors = validate(provider, empty, consumer); + Map errors = validate(provider, empty, consumer); assertEquals(2, errors.size()); assertTrue(errors.get(empty).contains("engine release")); assertTrue(errors.get(consumer).contains("engine release")); - Extension legacy = descriptor("Legacy", true, "arbitrary", SERVER_VERSION, null, true, + MetaData legacy = descriptor("Legacy", true, "arbitrary", SERVER_VERSION, null, true, pluginRequirement("Provider", "1.0.0")); assertTrue(validate(legacy, provider).isEmpty()); assertNull(ExtensionDependencies.getEngineError(legacy, SERVER_VERSION + ".123")); @@ -73,15 +79,26 @@ public void invalidAndConflictingDeclarationsFailClosed() { assertError(plugin("Consumer", pluginRequirement("Consumer", "1.0.0")), "depend on itself"); } + @Test + @SuppressWarnings({ "rawtypes", "unchecked" }) + public void unexpectedDeserializedDependencyTypesOnlyRejectTheirDescriptor() { + MetaData malformed = plugin("Malformed"); + malformed.setDependencies((List) Arrays.asList("not a dependency")); + MetaData independent = plugin("Independent"); + Map errors = validate(malformed, independent); + assertEquals(1, errors.size()); + assertTrue(errors.get(malformed).contains("dependency element")); + } + @Test public void pluginRequirementsUseNumericSameMajorMinimumVersions() { - Extension consumer = plugin("Consumer", pluginRequirement("Provider", "1.2.3")); + MetaData consumer = plugin("Consumer", pluginRequirement("Provider", "1.2.3")); for (String version : new String[] { "1.2.3", "1.2.10", "1.10.0", " 1.3.0 " }) { assertTrue(version, validate(consumer, versionedPlugin("Provider", version)).isEmpty()); } for (String version : new String[] { null, "", "1.2.2", "1.1.99", "0.99.99", "2.0.0", "1.2.3.4", "1.2.3-beta", "01.2.3", "1.2147483648.0" }) { - Map errors = validate(consumer, versionedPlugin("Provider", version)); + Map errors = validate(consumer, versionedPlugin("Provider", version)); assertEquals(String.valueOf(version), 1, errors.size()); assertTrue(errors.get(consumer).contains("installed version")); } @@ -91,25 +108,25 @@ public void pluginRequirementsUseNumericSameMajorMinimumVersions() { @Test public void dependenciesRequireAnEnabledPluginWithTheExactName() { - Extension consumer = plugin("Consumer", pluginRequirement("Provider", "1.0.0")); + MetaData consumer = plugin("Consumer", pluginRequirement("Provider", "1.0.0")); assertTrue(validate(consumer).get(consumer).contains("not installed")); assertTrue(validate(consumer, plugin("provider")).get(consumer).contains("not installed")); assertTrue(validate(consumer, plugin(" Provider ")).get(consumer).contains("not installed")); - Extension connector = descriptor("Provider", false, "1.0.0", null, "1.0.0", true); + MetaData connector = descriptor("Provider", false, "1.0.0", null, "1.0.0", true); assertTrue(validate(consumer, connector).get(consumer).contains("not installed")); - Extension disabled = descriptor("Provider", true, "1.0.0", null, "1.0.0", false); + MetaData disabled = descriptor("Provider", true, "1.0.0", null, "1.0.0", false); assertTrue(validate(consumer, disabled).get(consumer).contains("disabled")); - Extension connectorConsumer = descriptor("Provider", false, "1.0.0", null, "1.0.0", true, + MetaData connectorConsumer = descriptor("Provider", false, "1.0.0", null, "1.0.0", true, pluginRequirement("Provider", "1.0.0")); assertTrue(validate(connectorConsumer, plugin("Provider")).isEmpty()); } @Test public void allRequirementsMustBeSatisfied() { - Extension consumer = plugin("Consumer", pluginRequirement("First", "1.0.0"), + MetaData consumer = plugin("Consumer", pluginRequirement("First", "1.0.0"), pluginRequirement("Second", "2.0.0")); - Extension first = plugin("First"); + MetaData first = plugin("First"); assertTrue(validate(consumer, first).get(consumer).contains("Second")); assertTrue(validate(consumer, first, versionedPlugin("Second", "2.1.0")).isEmpty()); assertTrue(validate(consumer, first, versionedPlugin("Second", "1.0.0")).get(consumer).contains("Second")); @@ -117,10 +134,10 @@ public void allRequirementsMustBeSatisfied() { @Test public void duplicatePluginNamesRejectEveryProviderAndTheirConsumers() { - Extension first = plugin("Provider"); - Extension second = descriptor("Provider", true, "1.0.0", null, "1.0.0", false); - Extension consumer = plugin("Consumer", pluginRequirement("Provider", "1.0.0")); - Map errors = validate(consumer, first, second); + MetaData first = plugin("Provider"); + MetaData second = descriptor("Provider", true, "1.0.0", null, "1.0.0", false); + MetaData consumer = plugin("Consumer", pluginRequirement("Provider", "1.0.0")); + Map errors = validate(consumer, first, second); assertEquals(3, errors.size()); assertTrue(errors.get(first).contains("More than one plugin")); assertTrue(errors.get(second).contains("More than one plugin")); @@ -129,27 +146,27 @@ public void duplicatePluginNamesRejectEveryProviderAndTheirConsumers() { @Test public void disabledConsumersKeepDeclarationAndEngineChecksButMayHaveMissingPlugins() { - Extension disabled = descriptor("Disabled", true, "1.0.0", null, "1.0.0", false, + MetaData disabled = descriptor("Disabled", true, "1.0.0", null, "1.0.0", false, pluginRequirement("Missing", "1.0.0")); assertTrue(validate(disabled).isEmpty()); - Extension badDeclaration = descriptor("Disabled", true, "1.0.0", null, "1.0.0", false, + MetaData badDeclaration = descriptor("Disabled", true, "1.0.0", null, "1.0.0", false, pluginRequirement("Missing", "bad version")); assertTrue(validate(badDeclaration).get(badDeclaration).contains("numeric major.minor.patch")); - Extension badEngine = descriptor("Disabled", true, "1.0.0", null, "2.0.0", false, + MetaData badEngine = descriptor("Disabled", true, "1.0.0", null, "2.0.0", false, pluginRequirement("Missing", "1.0.0")); assertTrue(validate(badEngine).get(badEngine).contains("Requires engine API")); } @Test public void incompatibleProvidersInvalidateTransitiveConsumersInAnyInventoryOrder() { - Extension first = plugin("First", pluginRequirement("Second", "1.0.0")); - Extension second = plugin("Second", pluginRequirement("Third", "1.0.0")); - for (Extension third : Arrays.asList( + MetaData first = plugin("First", pluginRequirement("Second", "1.0.0")); + MetaData second = plugin("Second", pluginRequirement("Third", "1.0.0")); + for (MetaData third : Arrays.asList( descriptor("Third", true, "1.0.0", null, "2.0.0", true), plugin("Third", pluginRequirement("Missing", "1.0.0")), plugin("Third", requirement("invalid", null, "1.0.0")))) { - Map forward = validate(first, second, third); - Map reverse = validate(third, second, first); + Map forward = validate(first, second, third); + Map reverse = validate(third, second, first); assertEquals(3, forward.size()); assertEquals(forward, reverse); assertTrue(forward.get(first).contains("Second")); @@ -161,45 +178,59 @@ public void incompatibleProvidersInvalidateTransitiveConsumersInAnyInventoryOrde @Test public void cyclesAndTheirConsumersFailWithoutBlockingIndependentPlugins() { - Extension first = plugin("First", pluginRequirement("Second", "1.0.0")); - Extension second = plugin("Second", pluginRequirement("First", "1.0.0")); - Extension consumer = plugin("Consumer", pluginRequirement("First", "1.0.0")); - Extension independent = plugin("Independent"); - Map errors = validate(consumer, first, second, independent); + MetaData first = plugin("First", pluginRequirement("Second", "1.0.0")); + MetaData second = plugin("Second", pluginRequirement("First", "1.0.0")); + MetaData consumer = plugin("Consumer", pluginRequirement("First", "1.0.0")); + MetaData independent = plugin("Independent"); + Map errors = validate(consumer, first, second, independent); assertEquals(3, errors.size()); - for (String error : errors.values()) { - assertTrue(error.contains("Circular plugin dependencies")); + assertTrue(errors.values().stream().anyMatch(error -> error.contains("Circular plugin dependencies"))); + List inventory = Arrays.asList(consumer, first, second, independent); + for (int index = 0; index < inventory.size(); index++) { + Collections.rotate(inventory, 1); + Map reordered = validate(inventory.toArray(new MetaData[0])); + assertEquals(errors.keySet(), reordered.keySet()); + assertTrue(reordered.values().stream().anyMatch(error -> error.contains("Circular plugin dependencies"))); } assertFalse(errors.containsKey(independent)); } @Test public void failureInsideACyclePropagatesAndDiamondDependenciesResolve() { - Extension first = plugin("First", pluginRequirement("Second", "1.0.0")); - Extension second = plugin("Second", pluginRequirement("First", "1.0.0"), + MetaData first = plugin("First", pluginRequirement("Second", "1.0.0")); + MetaData second = plugin("Second", pluginRequirement("First", "1.0.0"), pluginRequirement("Missing", "1.0.0")); - Map errors = validate(first, second); + Map errors = validate(first, second); assertTrue(errors.get(first).contains("Second")); assertTrue(errors.get(second).contains("Missing")); - Extension base = plugin("Base"); - Extension left = plugin("Left", pluginRequirement("Base", "1.0.0")); - Extension right = plugin("Right", pluginRequirement("Base", "1.0.0")); - Extension top = plugin("Top", pluginRequirement("Left", "1.0.0"), pluginRequirement("Right", "1.0.0")); - assertTrue(validate(top, left, right, base).isEmpty()); + MetaData base = plugin("Base"); + MetaData left = plugin("Left", pluginRequirement("Base", "1.0.0")); + MetaData right = plugin("Right", pluginRequirement("Base", "1.0.0")); + MetaData top = plugin("Top", pluginRequirement("Left", "1.0.0"), pluginRequirement("Right", "1.0.0")); + List diamond = Arrays.asList(top, left, right, base); + for (int index = 0; index < diamond.size(); index++) { + Collections.rotate(diamond, 1); + assertTrue(validate(diamond.toArray(new MetaData[0])).isEmpty()); + } } @Test - public void longDependencyChainsDoNotUseTheCallStack() { - List chain = new ArrayList<>(); - int length = 10000; + public void dependencyChainsResolveInEitherOrder() { + List chain = new ArrayList<>(); + int length = 10; for (int index = 0; index < length - 1; index++) { chain.add(plugin("Plugin " + index, pluginRequirement("Plugin " + (index + 1), "1.0.0"))); } chain.add(plugin("Plugin " + (length - 1))); - assertTrue(ExtensionDependencies.validate(chain, SERVER_VERSION).isEmpty()); + assertTrue(validate(chain.toArray(new MetaData[0])).isEmpty()); + Collections.reverse(chain); + assertTrue(validate(chain.toArray(new MetaData[0])).isEmpty()); + Collections.reverse(chain); chain.set(length - 1, plugin("Plugin " + (length - 1), pluginRequirement("Missing", "1.0.0"))); - assertEquals(length, ExtensionDependencies.validate(chain, SERVER_VERSION).size()); + assertEquals(length, validate(chain.toArray(new MetaData[0])).size()); + Collections.reverse(chain); + assertEquals(length, validate(chain.toArray(new MetaData[0])).size()); } private static ExtensionDependency requirement(String type, String name, String version) { @@ -210,25 +241,42 @@ private static ExtensionDependency pluginRequirement(String name, String version return requirement("plugin", name, version); } - private static Extension plugin(String name, ExtensionDependency... dependencies) { + private MetaData plugin(String name, ExtensionDependency... dependencies) { return descriptor(name, true, "1.0.0", null, "1.0.0", true, dependencies); } - private static Extension versionedPlugin(String name, String version) { + private MetaData versionedPlugin(String name, String version) { return descriptor(name, true, version, null, "1.0.0", true); } - private static Extension descriptor(String name, boolean plugin, String pluginVersion, String mirthVersion, + private MetaData descriptor(String name, boolean plugin, String pluginVersion, String mirthVersion, String minApiVersion, boolean enabled, ExtensionDependency... dependencies) { - return new Extension(name, plugin, pluginVersion, mirthVersion, minApiVersion, - dependencies == null ? null : Arrays.asList(dependencies), enabled); + MetaData extension = plugin ? new PluginMetaData() : new ConnectorMetaData(); + extension.setName(name); + extension.setPluginVersion(pluginVersion); + extension.setMirthVersion(mirthVersion); + List requirements = new ArrayList<>(); + if (minApiVersion != null) { + requirements.add(requirement("engine-api", null, minApiVersion)); + } + if (dependencies != null) { + requirements.addAll(Arrays.asList(dependencies)); + } + extension.setDependencies(requirements); + if (enabled) { + disabledNames.remove(name); + } else { + disabledNames.add(name); + } + return extension; } - private static Map validate(Extension... extensions) { - return ExtensionDependencies.validate(Arrays.asList(extensions), SERVER_VERSION); + private Map validate(MetaData... extensions) { + return ExtensionDependencies.validate(Arrays.asList(extensions), SERVER_VERSION, + name -> !disabledNames.contains(name)); } - private static void assertError(Extension extension, String expected) { + private static void assertError(MetaData extension, String expected) { String error = ExtensionDependencies.getEngineError(extension, SERVER_VERSION); assertTrue(String.valueOf(error), error != null && error.contains(expected)); } diff --git a/server/src/test/java/com/mirth/connect/model/ExtensionMetaDataTest.java b/server/src/test/java/com/mirth/connect/model/ExtensionMetaDataTest.java index a75cb612e8..bd206efdda 100644 --- a/server/src/test/java/com/mirth/connect/model/ExtensionMetaDataTest.java +++ b/server/src/test/java/com/mirth/connect/model/ExtensionMetaDataTest.java @@ -6,9 +6,8 @@ package com.mirth.connect.model; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import java.util.ArrayList; @@ -17,6 +16,7 @@ import org.junit.Test; import com.mirth.connect.client.core.ExtensionDependency; +import com.mirth.connect.client.core.ExtensionDependencies; import com.mirth.connect.model.converters.ObjectXMLSerializer; public class ExtensionMetaDataTest { @@ -41,58 +41,31 @@ public void dependenciesRoundTripForPluginsAndConnectors() { } @Test - public void dependencySchemaRejectsTyposAndNestedFields() { - for (String dependency : new String[] { - "", - "pluginProvider1.0.0", - "", - "unexpected", - "unexpected", "" }) { - for (String root : new String[] { "pluginMetaData", "connectorMetaData" }) { - String xml = "<" + root + ">" + dependency + ""; - assertThrows(xml, RuntimeException.class, () -> serializer.deserialize(xml, MetaData.class)); - } - } - } - - @Test - public void apiLockRoundTripsForPluginsAndConnectors() { - for (MetaData metadata : new MetaData[] { new PluginMetaData(), new ConnectorMetaData() }) { - metadata.setName("Custom extension"); - metadata.setMinExtensionApiVersion("1.0.0"); - String xml = serializer.serialize(metadata); - assertTrue(xml.contains("1.0.0")); - MetaData restored = serializer.deserialize(xml, MetaData.class); - assertEquals(metadata.getClass(), restored.getClass()); - assertEquals("1.0.0", restored.getMinExtensionApiVersion()); - assertNull(restored.getMirthVersion()); - } - } - - @Test - public void legacyAndEmptyApiLocksRemainDistinctAfterDeserialization() { + public void absentEmptyAndNullListsRetainLegacyMatching() { for (String root : new String[] { "pluginMetaData", "connectorMetaData" }) { - String legacyXml = "<" + root + ">4.5.2"; - MetaData legacy = serializer.deserialize(legacyXml, MetaData.class); - assertNull(legacy.getMinExtensionApiVersion()); - assertEquals("4.5.2", legacy.getMirthVersion()); - String emptyXml = "<" + root + ">"; - assertEquals("", serializer.deserialize(emptyXml, MetaData.class).getMinExtensionApiVersion()); + for (String dependencies : new String[] { "", "", "" }) { + String xml = "<" + root + ">4.6.0" + dependencies + ""; + MetaData metadata = serializer.deserialize(xml, MetaData.class); + assertNull(ExtensionDependencies.getEngineError(metadata, "4.6.0.123")); + assertNotNull(ExtensionDependencies.getEngineError(metadata, "4.7.0")); + } } } @Test - public void explicitNullApiLocksRoundTripAsAbsentForPluginsAndConnectors() { + public void malformedRequirementsCannotFallBackToMatchingRelease() { for (String root : new String[] { "pluginMetaData", "connectorMetaData" }) { - String xml = "<" + root + ">4.5.2" - + ""; - MetaData metadata = serializer.deserialize(xml, MetaData.class); - assertNull(metadata.getMinExtensionApiVersion()); - String serialized = serializer.serialize(metadata); - assertFalse(serialized.contains("minExtensionApiVersion")); - MetaData restored = serializer.deserialize(serialized, MetaData.class); - assertNull(restored.getMinExtensionApiVersion()); - assertEquals("4.5.2", restored.getMirthVersion()); + for (String dependency : new String[] { + "", + "", + "", + "", + "", "not a dependency" }) { + String xml = "<" + root + ">4.6.0" + + dependency + ""; + MetaData metadata = serializer.deserialize(xml, MetaData.class); + assertNotNull(xml, ExtensionDependencies.getEngineError(metadata, "4.6.0")); + } } } } diff --git a/server/src/test/java/com/mirth/connect/server/ExtensionLoaderTest.java b/server/src/test/java/com/mirth/connect/server/ExtensionLoaderTest.java new file mode 100644 index 0000000000..05d3b23808 --- /dev/null +++ b/server/src/test/java/com/mirth/connect/server/ExtensionLoaderTest.java @@ -0,0 +1,166 @@ +/* + * Copyright (c) Open Integration Engine contributors. + * Licensed under the Mozilla Public License 2.0. + */ + +package com.mirth.connect.server; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.io.FileNotFoundException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; + +import com.mirth.connect.client.core.ControllerException; +import com.mirth.connect.model.MetaData; +import com.mirth.connect.model.PluginMetaData; +import com.mirth.connect.model.converters.ObjectXMLSerializer; +import com.mirth.connect.server.extprops.ExtensionStatuses; +import com.mirth.connect.server.tools.ClassPathResource; + +public class ExtensionLoaderTest { + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + private final ObjectXMLSerializer serializer = new ObjectXMLSerializer(getClass().getClassLoader()); + private final ExtensionLoader loader = new ExtensionLoader(serializer) { + @Override + protected String getServerVersion() { + return "4.6.0.123"; + } + }; + + @Test + public void validatesCanonicalPluginAndConnectorMetadata() throws Exception { + for (String root : List.of("pluginMetaData", "connectorMetaData")) { + for (String version : List.of("1.0.0", "1.0.1", "2.0.0", "", "invalid")) { + MetaData metadata = parse(root, "Example", engine(version)); + assertEquals(version, version.equals("1.0.0"), loader.isExtensionCompatible(metadata)); + assertEquals(version, version.equals("1.0.0"), + loader.getCompatibilityErrors(List.of(metadata), name -> true).isEmpty()); + } + MetaData legacy = parse(root, "Legacy", ""); + assertTrue(loader.isExtensionCompatible(legacy)); + legacy.setMirthVersion("4.5.2"); + assertFalse(loader.isExtensionCompatible(legacy)); + } + } + + @Test + public void invalidDependenciesAreIsolatedFromUnrelatedMetadata() throws Exception { + for (String invalid : List.of("", "not a dependency", + "")) { + MetaData broken = parse("pluginMetaData", "Broken", invalid); + MetaData consumer = parse("pluginMetaData", "Consumer", engine("1.0.0") + requires("Broken")); + MetaData good = parse("pluginMetaData", "Good", engine("1.0.0")); + assertEquals(Set.of("Broken", "Consumer"), + names(loader.getCompatibilityErrors(List.of(broken, consumer, good), name -> true))); + } + } + + @Test + public void providerIdentityUsesTheDeserializedType() throws Exception { + MetaData provider = serializer.deserialize(xml("pluginMetaData", "Provider", engine("1.0.0")) + .replace("", ""), MetaData.class); + MetaData consumer = parse("pluginMetaData", "Consumer", engine("1.0.0") + requires("Provider")); + assertFalse(provider instanceof PluginMetaData); + assertEquals(Set.of("Consumer"), names(loader.getCompatibilityErrors(List.of(provider, consumer), name -> true))); + } + + @Test + public void statusFailureRejectsAffectedProviderAndConsumersOnly() throws Exception { + MetaData broken = parse("pluginMetaData", "Broken", engine("1.0.0")); + MetaData consumer = parse("pluginMetaData", "Consumer", engine("1.0.0") + requires("Broken")); + MetaData good = parse("pluginMetaData", "Good", engine("1.0.0")); + assertEquals(Set.of("Broken", "Consumer"), names(loader.getCompatibilityErrors( + List.of(broken, consumer, good), name -> { + if (name.equals("Broken")) { + throw new IllegalStateException("Unavailable status"); + } + return true; + }))); + } + + @Test + public void unavailableVersionFailsValidation() throws Exception { + ExtensionLoader unavailable = new ExtensionLoader(serializer) { + @Override + protected String getServerVersion() throws FileNotFoundException { + throw new FileNotFoundException("Unavailable release metadata"); + } + }; + MetaData metadata = parse("pluginMetaData", "Example", engine("1.0.0")); + assertFalse(unavailable.isExtensionCompatible(metadata)); + assertThrows(ControllerException.class, () -> unavailable.getCompatibilityErrors(List.of(metadata), name -> true)); + } + + @Test + public void startupExposesOnlyAcceptedMetadataAndIsolatesUnreadableDescriptors() throws Exception { + Path root = temporary.newFolder("extensions").toPath(); + write(root, "good/plugin.xml", xml("pluginMetaData", "Good", engine("1.0.0"))); + write(root, "provider/plugin.xml", xml("pluginMetaData", "Provider", engine("2.0.0"))); + write(root, "consumer/plugin.xml", xml("pluginMetaData", "Consumer", engine("1.0.0") + requires("Provider"))); + write(root, "connector/SOURCE.XML", xml("connectorMetaData", "Connector", engine("1.0.0") + requires("Good"))); + write(root, "broken/plugin.xml", xml("pluginMetaData", "Broken", engine("1.0.0"))); + write(root, "null/plugin.xml", ""); + write(root, "foreign/plugin.xml", "not metadata"); + write(root, "nameless/plugin.xml", xml("pluginMetaData", " ", engine("1.0.0"))); + write(root, "install_temp/plugin.xml", xml("pluginMetaData", "Pending", engine("1.0.0"))); + write(root, ".install-work/plugin.xml", xml("pluginMetaData", "Incomplete", engine("1.0.0"))); + try (MockedStatic paths = mockStatic(ClassPathResource.class); + MockedStatic statuses = mockStatic(ExtensionStatuses.class)) { + paths.when(() -> ClassPathResource.getResourceURI("extensions")).thenReturn(root.toUri()); + ExtensionStatuses status = mock(ExtensionStatuses.class); + when(status.isEnabled(org.mockito.ArgumentMatchers.anyString())).thenReturn(true); + when(status.isEnabled("Broken")).thenThrow(new IllegalStateException("Unavailable status")); + statuses.when(ExtensionStatuses::getInstance).thenReturn(status); + assertEquals(Set.of("Good"), loader.getPluginMetaData().keySet()); + assertEquals(Set.of("Connector"), loader.getConnectorMetaData().keySet()); + assertEquals(Set.of("example"), loader.getConnectorProtocols().keySet()); + assertEquals(Set.of("Provider", "Consumer", "Broken"), loader.getInvalidMetaData().keySet()); + } + } + + private void write(Path root, String path, String xml) throws Exception { + Path file = root.resolve(path); + Files.createDirectories(file.getParent()); + Files.writeString(file, xml); + } + + private Set names(Map errors) { + return errors.keySet().stream().map(MetaData::getName).collect(Collectors.toSet()); + } + + private MetaData parse(String root, String name, String dependencies) { + return serializer.deserialize(xml(root, name, dependencies), MetaData.class); + } + + private String xml(String root, String name, String dependencies) { + return "<" + root + ">" + name + "1.0.0" + + "4.6.0" + dependencies + "" + + (root.equals("connectorMetaData") ? "example" : "") + ""; + } + + private String engine(String version) { + return ""; + } + + private String requires(String name) { + return ""; + } +} diff --git a/server/src/test/java/com/mirth/connect/server/controllers/DefaultExtensionControllerTest.java b/server/src/test/java/com/mirth/connect/server/controllers/DefaultExtensionControllerTest.java index 984607dcdf..80c908ade5 100644 --- a/server/src/test/java/com/mirth/connect/server/controllers/DefaultExtensionControllerTest.java +++ b/server/src/test/java/com/mirth/connect/server/controllers/DefaultExtensionControllerTest.java @@ -12,64 +12,45 @@ import static org.junit.Assert.assertTrue; import java.io.File; +import java.nio.file.Files; +import java.nio.charset.StandardCharsets; import java.util.zip.ZipEntry; import java.util.zip.ZipException; import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; -import org.junit.After; -import org.junit.Before; +import org.junit.Rule; import org.junit.Test; - -import com.mirth.connect.util.ZipTestUtils; +import org.junit.rules.TemporaryFolder; public class DefaultExtensionControllerTest { + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test(expected = ZipException.class) public void testExtractZipEntryZipSlipWithRelativePath() throws Exception { - DefaultExtensionController extensionController = new DefaultExtensionController(); - - File installTempDir = new File("tests/zipextraction"); - ZipEntry entry = new ZipEntry("../ZipSlip.txt"); - ZipFile zipFile = createTempZipFile("ZipSlip.txt"); - - extensionController.extractZipEntry(entry, installTempDir, zipFile); + File destination = temporaryFolder.newFolder("extraction"); + try (ZipFile zip = createTempZipFile("ZipSlip.txt")) { + ExtensionInstaller.extractZipEntry(new ZipEntry("../ZipSlip.txt"), destination, zip); + } } @Test public void testExtractZipEntryValidPath() throws Exception { - File installTempDir = new File("tests/zipextraction/"); - - DefaultExtensionController extensionController = new DefaultExtensionController(); - - ZipEntry entry = new ZipEntry("good.txt"); - ZipFile zipFile = createTempZipFile("good.txt"); - extensionController.extractZipEntry(entry, installTempDir, zipFile); - - File extractedFile = new File("tests/zipextraction/", "good.txt"); - assertTrue(extractedFile.exists()); - } - - @Before - public void createTestFolder() { - File installTempDir = new File("tests/zipextraction/"); - if (!installTempDir.exists()) { - installTempDir.mkdir(); - } else { - cleanupTestFolder(); - } - } - - @After - public void cleanupTestFolder() { - File tempDir = new File("tests/zipextraction/"); - if (tempDir.exists()) { - for (File file : tempDir.listFiles()) { - file.delete(); - } + File destination = temporaryFolder.newFolder("extraction"); + try (ZipFile zip = createTempZipFile("good.txt")) { + ExtensionInstaller.extractZipEntry(new ZipEntry("good.txt"), destination, zip); } + assertTrue(new File(destination, "good.txt").exists()); } private ZipFile createTempZipFile(String fileName) throws Exception { - return new ZipFile(ZipTestUtils.createTempZipFile(fileName)); + File archive = temporaryFolder.newFile("archive.zip"); + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(archive.toPath()))) { + zip.putNextEntry(new ZipEntry(fileName)); + zip.write("file contents".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + return new ZipFile(archive); } -} \ No newline at end of file +} diff --git a/server/src/test/java/com/mirth/connect/server/controllers/ExtensionDependencyInstallationTest.java b/server/src/test/java/com/mirth/connect/server/controllers/ExtensionDependencyInstallationTest.java index 0c8183db17..5c5c60b8ae 100644 --- a/server/src/test/java/com/mirth/connect/server/controllers/ExtensionDependencyInstallationTest.java +++ b/server/src/test/java/com/mirth/connect/server/controllers/ExtensionDependencyInstallationTest.java @@ -15,23 +15,21 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; -import java.io.FileNotFoundException; import java.io.IOException; -import java.lang.reflect.Constructor; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -40,85 +38,62 @@ import java.util.Map; import java.util.Set; import java.util.TreeMap; -import java.util.concurrent.atomic.AtomicInteger; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; -import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; -import org.mockito.MockedStatic; import com.mirth.connect.client.core.ControllerException; +import com.mirth.connect.model.PluginMetaData; import com.mirth.connect.model.converters.ObjectXMLSerializer; import com.mirth.connect.server.ExtensionLoader; import com.mirth.connect.server.controllers.ExtensionController.InstallationResult; import com.mirth.connect.server.extprops.ExtensionStatuses; -import com.mirth.connect.server.tools.ClassPathResource; -import com.mirth.connect.server.util.ResourceUtil; /** Exercises installation and administrative actions without an engine or database. */ public class ExtensionDependencyInstallationTest { @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder(); - private final List> staticMocks = new ArrayList<>(); private final Map enabled = new HashMap<>(); private Path extensions; private DefaultExtensionController controller; private ExtensionStatuses statuses; - private MockedStatic resources; + private ExtensionLoader loader; + private ObjectXMLSerializer serializer; @Before public void setUp() throws Exception { extensions = temporaryFolder.newFolder("extensions").toPath(); - ObjectXMLSerializer serializer = new ObjectXMLSerializer(getClass().getClassLoader()); - mockGlobal(ObjectXMLSerializer.class).when(ObjectXMLSerializer::getInstance).thenReturn(serializer); - + serializer = new ObjectXMLSerializer(getClass().getClassLoader()); statuses = mock(ExtensionStatuses.class); when(statuses.isEnabled(anyString())).thenAnswer(call -> enabled.getOrDefault(call.getArgument(0), true)); doAnswer(call -> { enabled.put(call.getArgument(0), call.getArgument(1)); return null; }).when(statuses).setEnabled(anyString(), anyBoolean()); - mockGlobal(ExtensionStatuses.class).when(ExtensionStatuses::getInstance).thenReturn(statuses); - - Constructor constructor = ExtensionLoader.class.getDeclaredConstructor(); - constructor.setAccessible(true); - ExtensionLoader loader = constructor.newInstance(); - mockGlobal(ExtensionLoader.class).when(ExtensionLoader::getInstance).thenReturn(loader); - - ControllerFactory factory = mock(ControllerFactory.class); - when(factory.createConfigurationController()).thenReturn(mock(ConfigurationController.class)); - mockGlobal(ControllerFactory.class).when(ControllerFactory::getFactory).thenReturn(factory); - mockGlobal(ExtensionController.class).when(ExtensionController::getExtensionsPath) - .thenAnswer(call -> extensions.toString()); - mockGlobal(ClassPathResource.class).when(() -> ClassPathResource.getResourceURI("extensions")) - .thenAnswer(call -> extensions.toUri()); - resources = mockGlobal(ResourceUtil.class); - resources.when(() -> ResourceUtil.getResourceStream(ExtensionLoader.class, "version.properties")) - .thenAnswer(call -> new ByteArrayInputStream("mirth.version=4.5.2.123".getBytes(StandardCharsets.UTF_8))); - controller = new DefaultExtensionController(); - } - - @After - public void closeMocks() { - Collections.reverse(staticMocks); - staticMocks.forEach(MockedStatic::close); + loader = spy(new ExtensionLoader(serializer) { + @Override + protected String getServerVersion() { + return "4.5.2.123"; + } + }); + doReturn(Collections.emptyMap()).when(loader).getPluginMetaData(); + createController(); } - private MockedStatic mockGlobal(Class type) { - MockedStatic mocked = mockStatic(type, CALLS_REAL_METHODS); - staticMocks.add(mocked); - return mocked; + private void createController() { + controller = new DefaultExtensionController(serializer, mock(ConfigurationController.class), loader, statuses, extensions.toFile()); } @Test public void bundledProvidersResolveRegardlessOfZipEntryOrder() throws Exception { for (boolean providerFirst : new boolean[] { false, true }) { extensions = temporaryFolder.newFolder().toPath(); + createController(); Map archive = new LinkedHashMap<>(); if (providerFirst) { archive.putAll(plugin("provider", "Provider", "2.1.0")); @@ -141,28 +116,11 @@ public void installedAndPreviouslyStagedProvidersAreAvailable() throws Exception assertTrue(Files.exists(staged("consumer/plugin.xml"))); } - @Test - public void invalidInstalledDescriptorsDoNotHideUnrelatedPluginMetadata() throws Exception { - writeInstalled(plugin("provider", "Provider", "2.1.0")); - String nameless = plugin("nameless", "Nameless", "1.0.0").get("nameless/plugin.xml") - .replace("Nameless", ""); - writeInstalled(Map.of("null/plugin.xml", "", "foreign/plugin.xml", "Unexpected type", - "nameless/plugin.xml", nameless)); - assertEquals(Set.of("Provider"), controller.getPluginMetaData().keySet()); - } - - @Test - public void statusLookupFailureDoesNotHideUnrelatedPluginMetadata() throws Exception { - writeInstalled(plugin("provider", "Provider", "2.1.0")); - writeInstalled(plugin("broken", "Broken", "2.1.0")); - when(statuses.isEnabled("Broken")).thenThrow(new IllegalStateException("Status unavailable for Broken")); - assertEquals(Set.of("Provider"), controller.getPluginMetaData().keySet()); - } - @Test public void connectorRequirementsUseTheSameArchiveInventory() throws Exception { for (String file : new String[] { "source.xml", "destination.xml" }) { extensions = temporaryFolder.newFolder().toPath(); + createController(); Map archive = plugin("connector", "Connector", "1.0.0", "Provider"); String metadata = archive.remove("connector/plugin.xml").replace("pluginMetaData", "connectorMetaData"); archive.put("connector/" + file, metadata); @@ -174,6 +132,19 @@ public void connectorRequirementsUseTheSameArchiveInventory() throws Exception { } } + @Test + public void archiveTypeOverridesUseTheDeserializedProviderType() throws Exception { + for (String attribute : new String[] { "class", "resolves-to" }) { + extensions = temporaryFolder.newFolder().toPath(); + createController(); + Map archive = plugin("provider", "Provider", "2.1.0"); + archive.put("provider/plugin.xml", archive.get("provider/plugin.xml") + .replace(" archive = plugin("provider", "Provider", "2.1.0"); + writeInstalled(archive); + PluginMetaData metadata = serializer.deserialize(archive.get("provider/plugin.xml"), PluginMetaData.class); + doReturn(Map.of("Provider", metadata)).when(loader).getPluginMetaData(); + controller.prepareExtensionForUninstallation("provider"); + assertEquals(List.of("provider"), Files.readAllLines(extensions.resolve("uninstall"))); + assertEquals(List.of("Provider"), Files.readAllLines(extensions.resolve(ExtensionController.EXTENSIONS_UNINSTALL_PROPERTIES_FILE))); + } + @Test public void statusPersistenceFailureRestoresMemoryAndAllowsRetry() throws Exception { writeInstalled(plugin("provider", "Provider", "2.1.0")); @@ -262,16 +244,15 @@ public void statusPersistenceFailureRestoresMemoryAndAllowsRetry() throws Except @Test public void unavailableVersionResourceRejectsEveryMutationUntilRetry() throws Exception { writeInstalled(plugin("provider", "Provider", "2.1.0")); - resources.when(() -> ResourceUtil.getResourceStream(ExtensionLoader.class, "version.properties")) - .thenThrow(new FileNotFoundException("Version resource unavailable")); + doThrow(new ControllerException("Could not determine extension compatibility.")) + .when(loader).getCompatibilityErrors(any(), any()); assertRejected(install(plugin("independent", "Independent", "1.0.0")), "Could not determine extension compatibility"); assertActionRejected(() -> controller.setExtensionEnabled("Provider", false), "Could not determine extension compatibility"); assertActionRejected(() -> controller.prepareExtensionForUninstallation("provider"), "Could not determine extension compatibility"); assertTrue(stagedFiles().isEmpty()); assertTrue(controller.isExtensionEnabled("Provider")); assertFalse(Files.exists(extensions.resolve("uninstall"))); - resources.when(() -> ResourceUtil.getResourceStream(ExtensionLoader.class, "version.properties")) - .thenAnswer(call -> new ByteArrayInputStream("mirth.version=4.5.2.123".getBytes(StandardCharsets.UTF_8))); + doCallRealMethod().when(loader).getCompatibilityErrors(any(), any()); assertAccepted(install(plugin("independent", "Independent", "1.0.0"))); controller.setExtensionEnabled("Provider", false); controller.prepareExtensionForUninstallation("provider"); @@ -336,23 +317,11 @@ public void uninstallRejectsCaseAliasesAndParentSegmentsBeforeMutation() throws assertTrue(stagedFiles().isEmpty()); } - @Test - public void caseAliasesAndReservedDirectoriesAreRejectedBeforeExtraction() throws Exception { - Map duplicateFile = plugin("provider", "Provider", "2.1.0"); - duplicateFile.put("provider/PLUGIN.XML", plugin("provider", "Other", "2.1.0").get("provider/plugin.xml")); - Map duplicatePackage = plugin("provider", "Provider", "2.1.0"); - duplicatePackage.putAll(plugin("PROVIDER", "Other", "2.1.0")); - for (Map archive : List.of(duplicateFile, duplicatePackage, - plugin("Install_Temp", "Provider", "2.1.0"))) { - assertNotNull("Ambiguous or reserved ZIP path was accepted", install(archive).getCause()); - assertTrue(stagedFiles().isEmpty()); - } - } - @Test public void caseOnlyPackageUpdatesCannotOverwriteInstalledOrStagedPackages() throws Exception { for (boolean staged : new boolean[] { false, true }) { extensions = temporaryFolder.newFolder().toPath(); + createController(); Map original = plugin("provider", "Provider", "2.1.0"); if (staged) { assertAccepted(install(original)); @@ -388,75 +357,6 @@ public void transitiveFailureRejectsNewConsumerButNotUnrelatedInstallation() thr assertAccepted(install(plugin("independent", "Independent", "1.0.0"))); } - @Test - public void malformedDescriptorRejectsTheWholeArchiveAndPreservesStaging() throws Exception { - assertAccepted(install(plugin("provider", "Provider", "2.1.0"))); - Map before = stagedFiles(); - Map broken = plugin("provider", "Provider", "2.2.0"); - broken.put("provider/source.xml", "Incomplete"); - assertNotNull(install(broken).getCause()); - assertEquals(before, stagedFiles()); - } - - @Test - public void incomingDescriptorsMustHaveAnExtensionTypeAndNonblankName() throws Exception { - String valid = plugin("provider", "Provider", "2.1.0").get("provider/plugin.xml"); - for (String invalid : List.of(valid.replace("Provider", ""), - valid.replace("Provider", " "), "", "Unexpected type")) { - Map archive = plugin("provider", "Provider", "2.1.0"); - archive.put("provider/plugin.xml", invalid); - assertNotNull("Invalid metadata identity was accepted", install(archive).getCause()); - assertTrue(stagedFiles().isEmpty()); - } - } - - @Test - public void extractionFailurePreservesPriorStagingAndCorrectedRetryReplacesWholePackage() throws Exception { - Map original = plugin("provider", "Provider", "2.1.0"); - original.put("provider/obsolete.jar", "old library"); - assertAccepted(install(original)); - Map before = stagedFiles(); - Map broken = plugin("provider", "Provider", "2.2.0"); - broken.put("provider/collision", "file blocks directory creation"); - broken.put("provider/collision/child", "cannot extract"); - assertNotNull(install(broken).getCause()); - assertEquals(before, stagedFiles()); - for (int retry = 0; retry < 2; retry++) { - assertAccepted(install(plugin("provider", "Provider", "2.2.0"))); - assertFalse(Files.exists(staged("provider/obsolete.jar"))); - assertEquals(2, stagedFiles().size()); - } - try (var paths = Files.list(extensions)) { - assertFalse("Failed extraction left private staging behind", - paths.anyMatch(path -> path.getFileName().toString().startsWith(".install-"))); - } - } - - @Test - public void failedPackagePromotionRestoresAllPriorStagedPackages() throws Exception { - Map original = plugin("one", "One", "2.1.0"); - original.putAll(plugin("two", "Two", "2.1.0")); - assertAccepted(install(original)); - Map before = stagedFiles(); - Map updated = plugin("one", "One", "2.2.0"); - updated.putAll(plugin("two", "Two", "2.2.0")); - AtomicInteger promotions = new AtomicInteger(); - try (MockedStatic files = mockStatic(Files.class, CALLS_REAL_METHODS)) { - files.when(() -> Files.move(any(Path.class), any(Path.class))).thenAnswer(call -> { - Path source = call.getArgument(0); - if (source.getParent().getFileName().toString().equals("payload") - && promotions.incrementAndGet() == 2) { - throw new IOException("Simulated failure after one package was promoted"); - } - return call.callRealMethod(); - }); - assertRejected(install(updated), "Simulated failure"); - } - assertEquals(2, promotions.get()); - assertEquals(before, stagedFiles()); - assertAccepted(install(updated)); - } - private static Map plugin(String path, String name, String version, String... providers) { StringBuilder requirements = new StringBuilder(""); for (String provider : providers) { diff --git a/server/src/test/java/com/mirth/connect/server/controllers/ExtensionInstallerTest.java b/server/src/test/java/com/mirth/connect/server/controllers/ExtensionInstallerTest.java new file mode 100644 index 0000000000..81dfd6fb55 --- /dev/null +++ b/server/src/test/java/com/mirth/connect/server/controllers/ExtensionInstallerTest.java @@ -0,0 +1,222 @@ +/* + * Copyright (c) Open Integration Engine contributors. + * Licensed under the Mozilla Public License 2.0. + */ + +package com.mirth.connect.server.controllers; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mockStatic; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; + +import com.mirth.connect.model.converters.ObjectXMLSerializer; +import com.mirth.connect.server.controllers.ExtensionInstaller.Result; + +/** Archive validation and rollback without controller or engine globals. */ +public class ExtensionInstallerTest { + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private Path extensions; + private ObjectXMLSerializer serializer; + + @Before + public void setUp() throws Exception { + extensions = temporaryFolder.newFolder("extensions").toPath(); + serializer = new ObjectXMLSerializer(getClass().getClassLoader()); + } + + @Test + public void caseAliasesAndReservedDirectoriesAreRejectedBeforeExtraction() throws Exception { + Map duplicateFile = plugin("provider", "Provider", "2.1.0"); + duplicateFile.put("provider/PLUGIN.XML", plugin("provider", "Other", "2.1.0").get("provider/plugin.xml")); + Map duplicatePackage = plugin("provider", "Provider", "2.1.0"); + duplicatePackage.putAll(plugin("PROVIDER", "Other", "2.1.0")); + for (Map archive : List.of(duplicateFile, duplicatePackage, + plugin("Install_Temp", "Provider", "2.1.0"))) { + assertNotNull("Ambiguous or reserved ZIP path was accepted", install(archive).cause()); + assertTrue(stagedFiles().isEmpty()); + } + } + + @Test + public void malformedDescriptorRejectsTheWholeArchiveAndPreservesStaging() throws Exception { + assertAccepted(install(plugin("provider", "Provider", "2.1.0"))); + Map before = stagedFiles(); + Map broken = plugin("provider", "Provider", "2.2.0"); + broken.put("provider/source.xml", "Incomplete"); + assertNotNull(install(broken).cause()); + assertEquals(before, stagedFiles()); + } + + @Test + public void incomingDescriptorsMustHaveAnExtensionTypeAndNonblankName() throws Exception { + String valid = plugin("provider", "Provider", "2.1.0").get("provider/plugin.xml"); + for (String invalid : List.of(valid.replace("Provider", ""), + valid.replace("Provider", " "), "", "Unexpected type")) { + Map archive = plugin("provider", "Provider", "2.1.0"); + archive.put("provider/plugin.xml", invalid); + assertNotNull("Invalid metadata identity was accepted", install(archive).cause()); + assertTrue(stagedFiles().isEmpty()); + } + } + + @Test + public void extractionFailurePreservesPriorStagingAndCorrectedRetryReplacesWholePackage() throws Exception { + Map original = plugin("provider", "Provider", "2.1.0"); + original.put("provider/obsolete.jar", "old library"); + assertAccepted(install(original)); + Map before = stagedFiles(); + Map broken = plugin("provider", "Provider", "2.2.0"); + broken.put("provider/collision", "file blocks directory creation"); + broken.put("provider/collision/child", "cannot extract"); + assertNotNull(install(broken).cause()); + assertEquals(before, stagedFiles()); + for (int retry = 0; retry < 2; retry++) { + assertAccepted(install(plugin("provider", "Provider", "2.2.0"))); + assertFalse(Files.exists(staged("provider/obsolete.jar"))); + assertEquals(2, stagedFiles().size()); + } + try (var paths = Files.list(extensions)) { + assertFalse("Failed extraction left private staging behind", + paths.anyMatch(path -> path.getFileName().toString().startsWith(".install-"))); + } + } + + @Test + public void failedPackagePromotionRestoresAllPriorStagedPackages() throws Exception { + Map original = plugin("one", "One", "2.1.0"); + original.putAll(plugin("two", "Two", "2.1.0")); + assertAccepted(install(original)); + Map before = stagedFiles(); + Map updated = plugin("one", "One", "2.2.0"); + updated.putAll(plugin("two", "Two", "2.2.0")); + AtomicInteger promotions = new AtomicInteger(); + try (MockedStatic files = mockStatic(Files.class, CALLS_REAL_METHODS)) { + files.when(() -> Files.move(any(Path.class), any(Path.class))).thenAnswer(call -> { + Path source = call.getArgument(0); + if (source.getParent().getFileName().toString().equals("payload") + && promotions.incrementAndGet() == 2) { + throw new IOException("Simulated failure after one package was promoted"); + } + return call.callRealMethod(); + }); + assertRejected(install(updated), "Simulated failure"); + } + assertEquals(2, promotions.get()); + assertEquals(before, stagedFiles()); + assertAccepted(install(updated)); + } + + @Test + public void failedRollbackRetainsOriginalPackageAndReportsRecoveryLocation() throws Exception { + Map original = plugin("provider", "Provider", "2.1.0"); + assertAccepted(install(original)); + Result result; + try (MockedStatic files = mockStatic(Files.class, CALLS_REAL_METHODS)) { + files.when(() -> Files.move(any(Path.class), any(Path.class))).thenAnswer(call -> { + Path source = call.getArgument(0); + String directory = source.getParent().getFileName().toString(); + if (directory.equals("payload") || directory.equals("backup")) { + throw new IOException("Simulated promotion and rollback failure"); + } + return call.callRealMethod(); + }); + result = install(plugin("provider", "Provider", "2.2.0")); + } + assertRejected(result, "recovery files retained at"); + try (var paths = Files.list(extensions)) { + Path work = paths.filter(path -> path.getFileName().toString().startsWith(".install-")).findFirst().orElseThrow(); + Path backup = work.resolve("backup"); + assertTrue(result.cause().toString().contains(backup.toString())); + assertEquals(original.get("provider/plugin.xml"), Files.readString(backup.resolve("provider/plugin.xml"))); + assertEquals("Provider", Files.readString(backup.resolve("provider/payload.txt"))); + } + } + + private static Map plugin(String path, String name, String version, String... providers) { + StringBuilder requirements = new StringBuilder(""); + for (String provider : providers) { + requirements.append(""); + } + Map entries = new LinkedHashMap<>(); + // Place payload before the descriptor to prove rejection precedes extraction. + entries.put(path + "/payload.txt", name); + entries.put(path + "/plugin.xml", "" + name + + "" + version + "" + requirements + + ""); + return entries; + } + + private Result install(Map entries) throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes)) { + Set directories = new HashSet<>(); + for (Map.Entry entry : entries.entrySet()) { + String parent = entry.getKey().substring(0, entry.getKey().lastIndexOf('/') + 1); + if (directories.add(parent)) { + zip.putNextEntry(new ZipEntry(parent)); + zip.closeEntry(); + } + zip.putNextEntry(new ZipEntry(entry.getKey())); + zip.write(entry.getValue().getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + } + return new ExtensionInstaller(extensions.toFile(), serializer).install(new ByteArrayInputStream(bytes.toByteArray()), incoming -> {}); + } + + private Path staged(String relative) { + return extensions.resolve("install_temp").resolve(relative); + } + + private Map stagedFiles() throws Exception { + Map contents = new TreeMap<>(); + Path root = staged(""); + if (Files.exists(root)) { + try (var paths = Files.walk(root)) { + for (Path path : (Iterable) paths.filter(Files::isRegularFile)::iterator) { + contents.put(root.relativize(path).toString(), Files.readString(path)); + } + } + } + return contents; + } + + private static void assertAccepted(Result result) { + assertNull("Installation failed: " + result.cause(), result.cause()); + } + + private static void assertRejected(Result result, String requirement) { + assertNotNull("Installation unexpectedly succeeded", result.cause()); + assertTrue(result.cause().toString(), result.cause().toString().contains(requirement)); + } + +} diff --git a/server/src/test/java/com/mirth/connect/server/launcher/ExtensionCompatibilityTest.java b/server/src/test/java/com/mirth/connect/server/launcher/ExtensionCompatibilityTest.java deleted file mode 100644 index 867abb70c5..0000000000 --- a/server/src/test/java/com/mirth/connect/server/launcher/ExtensionCompatibilityTest.java +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright (c) Open Integration Engine contributors. - * Licensed under the Mozilla Public License 2.0. - */ - -package com.mirth.connect.server.launcher; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.mockStatic; - -import java.io.ByteArrayInputStream; -import java.io.FileNotFoundException; -import java.io.StringReader; -import java.nio.charset.StandardCharsets; - -import javax.xml.parsers.DocumentBuilderFactory; - -import org.junit.Test; -import org.mockito.MockedStatic; -import org.w3c.dom.Element; -import org.xml.sax.InputSource; - -import com.mirth.connect.model.MetaData; -import com.mirth.connect.model.PluginMetaData; -import com.mirth.connect.model.converters.ObjectXMLSerializer; -import com.mirth.connect.server.ExtensionLoader; -import com.mirth.connect.server.util.ResourceUtil; - -public class ExtensionCompatibilityTest { - - @Test - public void launcherAndLoaderAgreeForPluginAndConnectorMetadata() throws Exception { - ObjectXMLSerializer serializer = new ObjectXMLSerializer(getClass().getClassLoader()); - Object[][] cases = { - { "4.5.1, 4.5.2 ", "4.5.2.123", true }, - { "4.5.2", "99.0.0", false }, - { "4.5.21.0.0", "99.0.0", true }, - { " 1.0.0 ", "99.0.0", true }, - { "4.5.2", "4.5.2", false }, - { "4.5.2invalid", "4.5.2", false }, - { "4.5.21.0.1", "4.5.2", false }, - { "4.5.2", "4.5.2", true }, - { "4.5.2", "99.0.0", false }, - { "4.5.22.0.0", "4.5.2", true }, - { "4.5.21.0.0", "4.5.2", false }, - { "1.0.0", "99.0.0", true }, - { "", "99.0.0", true }, - { "4.5.2", "4.5.2", true }, - { "4.5.2", "4.5.2", true }, - { "4.5.22.0.0", "4.5.2", false }, - { "4.5.2", "4.5.2", true }, - { "2.0.0", "99.0.0", false }, - { "", "4.5.2", false } - }; - - try (MockedStatic resources = mockStatic(ResourceUtil.class)) { - for (Object[] testCase : cases) { - String serverVersion = (String) testCase[1]; - resources.when(() -> ResourceUtil.getResourceStream(ExtensionLoader.class, "version.properties")) - .thenAnswer(invocation -> new ByteArrayInputStream(("mirth.version=" + serverVersion).getBytes(StandardCharsets.UTF_8))); - for (String root : new String[] { "pluginMetaData", "connectorMetaData" }) { - String xml = "<" + root + ">" + testCase[0] + ""; - assertEquals(xml, testCase[2], MirthLauncher.isExtensionCompatible(parse(xml), serverVersion)); - assertEquals(xml, testCase[2], ExtensionLoader.getInstance().isExtensionCompatible(serializer.deserialize(xml, MetaData.class))); - } - } - } - } - - @Test - public void launcherOnlyReadsDirectMetadataChildren() throws Exception { - assertFalse(MirthLauncher.isExtensionCompatible(parse("" - + "4.5.21.0.0" - + ""), "4.5.2")); - assertTrue(MirthLauncher.isExtensionCompatible(parse("4.5.2" - + "invalid"), "4.5.2")); - } - - @Test - public void unavailableReleaseMetadataRetainsLoaderFailureBehavior() throws Exception { - try (MockedStatic resources = mockStatic(ResourceUtil.class)) { - resources.when(() -> ResourceUtil.getResourceStream(ExtensionLoader.class, "version.properties")) - .thenThrow(new FileNotFoundException("Simulated unavailable release metadata")); - PluginMetaData metadata = new PluginMetaData(); - metadata.setMirthVersion("4.5.2"); - assertFalse(ExtensionLoader.getInstance().isExtensionCompatible(metadata)); - metadata.setMinExtensionApiVersion("1.0.0"); - assertFalse(ExtensionLoader.getInstance().isExtensionCompatible(metadata)); - } - } - - private Element parse(String xml) throws Exception { - return DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(new InputSource(new StringReader(xml))).getDocumentElement(); - } -} diff --git a/server/src/test/java/com/mirth/connect/server/launcher/ExtensionDependenciesTest.java b/server/src/test/java/com/mirth/connect/server/launcher/ExtensionDependenciesTest.java deleted file mode 100644 index 5b6f4b4ae0..0000000000 --- a/server/src/test/java/com/mirth/connect/server/launcher/ExtensionDependenciesTest.java +++ /dev/null @@ -1,297 +0,0 @@ -/* - * Copyright (c) Open Integration Engine contributors. - * Licensed under the Mozilla Public License 2.0. - */ - -package com.mirth.connect.server.launcher; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; -import static org.mockito.Mockito.mock; - -import java.io.File; -import java.io.StringReader; -import java.lang.reflect.Field; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -import javax.xml.parsers.DocumentBuilderFactory; - -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; -import org.w3c.dom.Element; -import org.xml.sax.InputSource; - -import com.mirth.connect.client.core.ExtensionDependencies; -import com.mirth.connect.client.core.ExtensionDependencies.Extension; -import com.mirth.connect.model.MetaData; -import com.mirth.connect.model.PluginMetaData; -import com.mirth.connect.model.converters.ObjectXMLSerializer; -import com.mirth.connect.server.extprops.LoggerWrapper; - -public class ExtensionDependenciesTest { - @Rule - public TemporaryFolder temporary = new TemporaryFolder(); - - private Object originalLogger; - - @Before - public void captureLogging() throws Exception { - Field field = MirthLauncher.class.getDeclaredField("logger"); - field.setAccessible(true); - originalLogger = field.get(null); - field.set(null, mock(LoggerWrapper.class)); - } - - @After - public void restoreLogging() throws Exception { - Field field = MirthLauncher.class.getDeclaredField("logger"); - field.setAccessible(true); - field.set(null, originalLogger); - } - - @Test - public void serializedMetadataAndLauncherResolveTheSameGraph() throws Exception { - ObjectXMLSerializer serializer = new ObjectXMLSerializer(getClass().getClassLoader()); - List xml = Arrays.asList( - metadata("pluginMetaData", "Consumer", "1.0.0", engine() + requires("Provider", "1.1.0")), - metadata("pluginMetaData", "Provider", "1.2.0", engine()), - metadata("connectorMetaData", "Connector", "1.0.0", engine() + requires("Consumer", "1.0.0"))); - for (boolean providerEnabled : new boolean[] { true, false }) { - List fromModel = new ArrayList<>(); - List fromLauncher = new ArrayList<>(); - for (String source : xml) { - MetaData metadata = serializer.deserialize(source, MetaData.class); - boolean enabled = providerEnabled || !"Provider".equals(metadata.getName()); - fromModel.add(new Extension(metadata.getName(), metadata instanceof PluginMetaData, - metadata.getPluginVersion(), metadata.getMirthVersion(), metadata.getMinExtensionApiVersion(), - metadata.getDependencies(), enabled)); - fromLauncher.add(MirthLauncher.readExtension(parse(serializer.serialize(metadata)), enabled)); - } - Set expected = providerEnabled ? Collections.emptySet() : new HashSet<>(Arrays.asList("Consumer", "Connector")); - assertEquals(expected, invalidNames(fromModel)); - assertEquals(expected, invalidNames(fromLauncher)); - } - } - - @Test - public void typedEngineRequirementsUseTheSameValidationAsSerializedMetadata() throws Exception { - ObjectXMLSerializer serializer = new ObjectXMLSerializer(getClass().getClassLoader()); - String[] requirements = { - engine(), - "", - "", - "", - "", - engine() + engine(), - "", - "" - }; - for (String requirement : requirements) { - for (String root : new String[] { "pluginMetaData", "connectorMetaData" }) { - String xml = metadata(root, "Example", "1.0.0", requirement); - MetaData model = serializer.deserialize(xml, MetaData.class); - Extension extension = new Extension(model.getName(), model instanceof PluginMetaData, - model.getPluginVersion(), model.getMirthVersion(), model.getMinExtensionApiVersion(), - model.getDependencies(), true); - assertEquals(xml, ExtensionDependencies.getEngineError(extension, "99.0.0") == null, - MirthLauncher.isExtensionCompatible(parse(xml), "99.0.0")); - } - } - assertTrue(MirthLauncher.isExtensionCompatible(parse(metadata("pluginMetaData", "Example", "1.0.0", engine())), "99.0.0")); - assertFalse(MirthLauncher.isExtensionCompatible(parse(metadata("pluginMetaData", "Example", "1.0.0", "")), "99.0.0")); - } - - @Test - public void malformedDeclarationsCannotDisappearDuringBootstrap() throws Exception { - ObjectXMLSerializer serializer = new ObjectXMLSerializer(getClass().getClassLoader()); - String[] declarations = { - "", - "", - "unexpected" + engine(), - "", - "engine-api1.0.0", - "unexpected", - "", - "ignored" - }; - for (String declaration : declarations) { - String xml = metadata("pluginMetaData", "Example", "1.0.0", declaration); - assertFalse(declaration, MirthLauncher.isExtensionCompatible(parse(xml), "4.5.2")); - try { - serializer.deserialize(xml, MetaData.class); - fail("Malformed dependencies must also fail server deserialization: " + declaration); - } catch (com.mirth.connect.donkey.util.xstream.SerializerException expected) { - // Both metadata readers must fail closed before an extension can load. - } - } - assertFalse(MirthLauncher.isExtensionCompatible(parse("4.5.2" - + "" + engine() + ""), "4.5.2")); - String nullList = "4.5.2" - + "" + engine() + ""; - assertNull(serializer.deserialize(nullList, MetaData.class).getDependencies()); - assertTrue(MirthLauncher.isExtensionCompatible(parse(nullList), "4.5.2")); - assertFalse(MirthLauncher.isExtensionCompatible(parse(nullList), "99.0.0")); - } - - @Test - public void metadataOverridesAndDuplicateVersionsCannotBypassPluginValidation() throws Exception { - ObjectXMLSerializer serializer = new ObjectXMLSerializer(getClass().getClassLoader()); - String source = metadata("pluginMetaData", "Provider", "1.0.0", engine()); - String connectorOverride = source.replace("", ""); - assertFalse(serializer.deserialize(connectorOverride, MetaData.class) instanceof PluginMetaData); - assertFalse(MirthLauncher.isExtensionCompatible(parse(connectorOverride), "4.5.2")); - for (String field : new String[] { "name", "pluginVersion", "mirthVersion", "minExtensionApiVersion" }) { - String duplicates = source.replace("", "<" + field + ">1.0.0" - + "<" + field + ">2.0.0"); - assertFalse(field, MirthLauncher.isExtensionCompatible(parse(duplicates), "4.5.2")); - try { - serializer.deserialize(duplicates, MetaData.class); - fail("Duplicate metadata must fail server deserialization: " + field); - } catch (com.mirth.connect.donkey.util.xstream.SerializerException expected) { - // The launcher must not choose one of multiple provider identities or versions. - } - } - } - - @Test - public void classpathResolutionDoesNotDependOnDiscoveryOrderAndFiltersLibraryTypes() throws Exception { - for (boolean consumerFirst : new boolean[] { true, false }) { - File root = temporary.newFolder(); - if (consumerFirst) { - write(root, "consumer", "Consumer", "1.0.0", engine() + requires("Provider", "1.0.0")); - } - write(root, "provider", "Provider", "1.2.0", engine()); - if (!consumerFirst) { - write(root, "consumer", "Consumer", "1.0.0", engine() + requires("Provider", "1.0.0")); - } - assertLibraries(root, Collections.emptySet(), "consumer", "provider"); - } - } - - @Test - public void missingDisabledAndIncompatibleProvidersExcludeAllDependents() throws Exception { - for (String failure : Arrays.asList("missing", "disabled", "plugin-version", "engine-version")) { - File root = temporary.newFolder(); - write(root, "consumer", "Consumer", "1.0.0", engine() + requires("Middle", "1.0.0")); - write(root, "middle", "Middle", "1.0.0", engine() + requires("Provider", "1.0.0")); - write(root, "independent", "Independent", "1.0.0", engine()); - if (!failure.equals("missing")) { - write(root, "provider", "Provider", failure.equals("plugin-version") ? "2.0.0" : "1.0.0", - failure.equals("engine-version") ? "" : engine()); - } - assertLibraries(root, failure.equals("disabled") ? Collections.singleton("Provider") : Collections.emptySet(), - failure.equals("plugin-version") ? new String[] { "independent", "provider" } : new String[] { "independent" }); - } - } - - @Test - public void cyclesAndAmbiguousProvidersExcludeTheirDependents() throws Exception { - File cycle = temporary.newFolder(); - write(cycle, "a", "A", "1.0.0", engine() + requires("B", "1.0.0")); - write(cycle, "b", "B", "1.0.0", engine() + requires("A", "1.0.0")); - write(cycle, "consumer", "Consumer", "1.0.0", engine() + requires("A", "1.0.0")); - write(cycle, "independent", "Independent", "1.0.0", engine()); - assertLibraries(cycle, Collections.emptySet(), "independent"); - - File duplicate = temporary.newFolder(); - write(duplicate, "provider1", "Provider", "1.0.0", engine()); - write(duplicate, "provider2", "Provider", "1.0.0", engine()); - write(duplicate, "consumer", "Consumer", "1.0.0", engine() + requires("Provider", "1.0.0")); - assertLibraries(duplicate, Collections.emptySet()); - } - - @Test - public void connectorsAndUnparseablePluginsCannotSatisfyPluginRequirements() throws Exception { - for (boolean connector : new boolean[] { true, false }) { - File root = temporary.newFolder(); - write(root, "consumer", "Consumer", "1.0.0", engine() + requires("Provider", "1.0.0")); - File provider = new File(root, "provider"); - assertTrue(provider.mkdir()); - String xml = connector ? metadata("connectorMetaData", "Provider", "1.0.0", engine()) - : metadata("pluginMetaData", "Provider", "1.0.0", ""); - writeFiles(provider, connector ? "source.xml" : "plugin.xml", xml); - assertLibraries(root, Collections.emptySet(), connector ? new String[] { "provider" } : new String[0]); - } - } - - @Test - public void malformedDescriptorIdentityDoesNotAffectUnrelatedExtensions() throws Exception { - String[] invalid = { - "", - "not metadata", - metadata("pluginMetaData", "Provider", "1.0.0", engine()).replace("Provider", ""), - metadata("pluginMetaData", " ", "1.0.0", engine()) - }; - for (String descriptor : invalid) { - File root = temporary.newFolder(); - File broken = new File(root, "broken"); - assertTrue(broken.mkdir()); - writeFiles(broken, "plugin.xml", descriptor); - write(root, "independent", "Independent", "1.0.0", engine()); - assertLibraries(root, Collections.emptySet(), "independent"); - } - } - - private Set invalidNames(List extensions) { - return ExtensionDependencies.validate(extensions, "99.0.0").keySet().stream().map(Extension::getName).collect(Collectors.toSet()); - } - - private void assertLibraries(File root, Set disabled, String... names) throws Exception { - List urls = new ArrayList<>(); - MirthLauncher.addExtensionsToClasspath(urls, "4.5.2", root, name -> !disabled.contains(name)); - Set expected = new HashSet<>(); - for (String name : names) { - expected.add(new File(root, name + "/server.jar").toURI().toURL()); - expected.add(new File(root, name + "/shared.jar").toURI().toURL()); - } - assertEquals(expected, new HashSet<>(urls)); - assertEquals("Libraries must not be added more than once", expected.size(), urls.size()); - } - - private void write(File root, String path, String name, String version, String dependencies) throws Exception { - File directory = new File(root, path); - assertTrue(directory.mkdir()); - writeFiles(directory, "plugin.xml", metadata("pluginMetaData", name, version, dependencies)); - } - - private void writeFiles(File directory, String metadataName, String xml) throws Exception { - Files.write(new File(directory, metadataName).toPath(), xml.getBytes(StandardCharsets.UTF_8)); - for (String library : Arrays.asList("server.jar", "shared.jar", "client.jar")) { - Files.write(new File(directory, library).toPath(), new byte[0]); - } - } - - private String metadata(String root, String name, String version, String dependencies) { - return "<" + root + ">" + name + "" + version + "" - + "4.5.2" + dependencies + "" - + "" - + ""; - } - - private String engine() { - return ""; - } - - private String requires(String name, String minimum) { - return ""; - } - - private Element parse(String xml) throws Exception { - return DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(new InputSource(new StringReader(xml))).getDocumentElement(); - } -} diff --git a/server/src/test/java/com/mirth/connect/server/launcher/ExtensionLibrariesTest.java b/server/src/test/java/com/mirth/connect/server/launcher/ExtensionLibrariesTest.java new file mode 100644 index 0000000000..8a46cc2003 --- /dev/null +++ b/server/src/test/java/com/mirth/connect/server/launcher/ExtensionLibrariesTest.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) Open Integration Engine contributors. + * Licensed under the Mozilla Public License 2.0. + */ + +package com.mirth.connect.server.launcher; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; + +import java.io.File; +import java.lang.reflect.Field; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import com.mirth.connect.server.extprops.LoggerWrapper; + +public class ExtensionLibrariesTest { + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + private Object originalLogger; + private Field logger; + + @Before + public void captureLogging() throws Exception { + logger = MirthLauncher.class.getDeclaredField("logger"); + logger.setAccessible(true); + originalLogger = logger.get(null); + logger.set(null, mock(LoggerWrapper.class)); + } + + @After + public void restoreLogging() throws Exception { + logger.set(null, originalLogger); + } + + @Test + public void discoversEnabledServerAndSharedLibrariesBeforeCompatibilityValidation() throws Exception { + File root = temporary.newFolder(); + File enabled = write(root, "enabled", "Enabled", ""); + write(root, "disabled", "Disabled", ""); + write(root, "install_temp", "Pending", ""); + write(root, ".install-work", "Incomplete", ""); + List urls = new ArrayList<>(); + MirthLauncher.addExtensionsToClasspath(urls, root, name -> !name.equals("Disabled")); + assertEquals(List.of(new File(enabled, "server.jar").toURI().toURL(), + new File(enabled, "shared.jar").toURI().toURL()), urls); + } + + @Test + public void canonicalTypeOverridesDoNotPreventLibraryDiscovery() throws Exception { + File root = temporary.newFolder(); + File extension = write(root, "example", "Example", " resolves-to=\"connectorMetaData\""); + List urls = new ArrayList<>(); + MirthLauncher.addExtensionsToClasspath(urls, root, name -> true); + assertEquals(List.of(new File(extension, "server.jar").toURI().toURL(), + new File(extension, "shared.jar").toURI().toURL()), urls); + } + + @Test + public void nestedDependencyNameDoesNotDetermineExtensionStatus() throws Exception { + File root = temporary.newFolder(); + File extension = write(root, "consumer", "Consumer", ""); + Path descriptor = new File(extension, "plugin.xml").toPath(); + Files.writeString(descriptor, Files.readString(descriptor).replace("", "").replace("Consumer", + "" + + "ProviderConsumer")); + List urls = new ArrayList<>(); + MirthLauncher.addExtensionsToClasspath(urls, root, name -> name.equals("Consumer")); + assertEquals(2, urls.size()); + } + + @Test + public void malformedDescriptorAndStatusFailureDoNotHideUnrelatedLibraries() throws Exception { + File root = temporary.newFolder(); + write(root, "broken", "Broken", ""); + File malformed = write(root, "malformed", "Malformed", ""); + Files.writeString(new File(malformed, "plugin.xml").toPath(), ""); + File good = write(root, "good", "Good", ""); + List urls = new ArrayList<>(); + MirthLauncher.addExtensionsToClasspath(urls, root, name -> { + if (name.equals("Broken")) { + throw new IllegalStateException("Unavailable status"); + } + return true; + }); + assertEquals(List.of(new File(good, "server.jar").toURI().toURL(), + new File(good, "shared.jar").toURI().toURL()), urls); + } + + private File write(File root, String path, String name, String attributes) throws Exception { + File directory = new File(root, path); + Files.createDirectory(directory.toPath()); + Files.writeString(new File(directory, "plugin.xml").toPath(), + "" + name + "" + + "" + + "" + + "" + + ""); + for (String library : List.of("server.jar", "shared.jar", "client.jar")) { + Files.createFile(new File(directory, library).toPath()); + } + return directory; + } +}