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
new file mode 100644
index 0000000000..4354f5a4b2
--- /dev/null
+++ b/docs/extension-compatibility.md
@@ -0,0 +1,287 @@
+# Writing and migrating extensions
+
+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
+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).
+
+## 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. 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).
+
+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
+
+
+
+ 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
+
+
+
+ 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.
+
+### 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.
+ 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
+
+
+
+```
+
+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.
+
+### 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 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
+declaration and restore the tested release list; leaving an API requirement
+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. 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. For example:
+
+```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.
+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. 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, 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.
+
+## Compatibility rules
+
+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 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
+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.
+
+## Maintaining the API version
+
+`ExtensionCompatibility.API_VERSION` is one compatibility contract covering
+OIE's existing extension-facing classes across the server, shared models, and
+Administrator client. Engine maintainers should:
+
+- Leave it unchanged for product releases that preserve the extension contract.
+- 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. Extension authors
+ must review, update, and test their code before declaring that major version.
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..baed989259
--- /dev/null
+++ b/server/src/main/java/com/mirth/connect/client/core/ExtensionCompatibility.java
@@ -0,0 +1,72 @@
+/*
+ * 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;
+
+/** 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";
+
+ 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 minimumApiVersion,
+ String serverVersion) {
+ if (minimumApiVersion != null) {
+ // An invalid explicit requirement must never fall back to the legacy release check.
+ return isApiCompatible(minimumApiVersion, 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]));
+ }
+
+ static boolean isValidVersion(String version) {
+ return parseApiVersion(version) != null;
+ }
+
+ 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/client/core/ExtensionDependencies.java b/server/src/main/java/com/mirth/connect/client/core/ExtensionDependencies.java
new file mode 100644
index 0000000000..0018b4aa41
--- /dev/null
+++ b/server/src/main/java/com/mirth/connect/client/core/ExtensionDependencies.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright (c) Open Integration Engine contributors.
+ * Licensed under the Mozilla Public License 2.0.
+ */
+
+package com.mirth.connect.client.core;
+
+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.Set;
+import java.util.function.Predicate;
+
+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() {}
+
+ /** Returns the first declaration or engine-compatibility error, or null on success. */
+ public static String getEngineError(MetaData extension, String serverVersion) {
+ String minimumApiVersion = null;
+ Set pluginRequirements = new HashSet<>();
+
+ 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 (minimumApiVersion != null) {
+ return "Declare the engine API requirement only once.";
+ }
+ if (dependency.getName() != null) {
+ return "An engine-api dependency must not declare a name.";
+ }
+ 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 instanceof PluginMetaData && name.equals(extension.getName())) {
+ return "A plugin cannot depend on itself: '" + name + "'.";
+ }
+ } else {
+ return "Unknown dependency type '" + dependency.getType() + "'.";
+ }
+ }
+
+ if (!ExtensionCompatibility.isCompatible(extension.getMirthVersion(), minimumApiVersion, serverVersion)) {
+ return minimumApiVersion == null
+ ? "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;
+ }
+
+ /** 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 (MetaData extension : inventory) {
+ String error = getEngineError(extension, serverVersion);
+ if (error != null) {
+ errors.put(extension, error);
+ }
+ if (extension instanceof PluginMetaData && plugins.putIfAbsent(extension.getName(), extension) != null) {
+ duplicateNames.add(extension.getName());
+ }
+ if (enabled.test(extension.getName())) {
+ enabledExtensions.add(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 : dependencies(extension)) {
+ if (!"plugin".equals(dependency.getType())) {
+ continue;
+ }
+ String name = dependency.getName();
+ 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 (!enabledExtensions.contains(provider)) {
+ error = "Required plugin '" + name + "' is disabled.";
+ } 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.getPluginVersion() + "'.";
+ }
+ if (error != null) {
+ errors.put(extension, error);
+ break;
+ }
+ }
+ }
+
+ Set visiting = new HashSet<>();
+ Set checked = new HashSet<>();
+ for (MetaData extension : inventory) {
+ if (enabledExtensions.contains(extension)) {
+ canLoad(extension, plugins, errors, visiting, checked);
+ }
+ }
+ 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
new file mode 100644
index 0000000000..ccdc149152
--- /dev/null
+++ b/server/src/main/java/com/mirth/connect/client/core/ExtensionDependency.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) Open Integration Engine contributors.
+ * Licensed under the Mozilla Public License 2.0.
+ */
+
+package com.mirth.connect.client.core;
+
+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() {}
+
+ 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 a0b59bdff9..bfae0e18f2 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;
@@ -27,6 +28,7 @@ public abstract class MetaData {
private String name;
private String author;
private String mirthVersion;
+ private List dependencies;
private String pluginVersion;
private String url;
private String description;
@@ -72,6 +74,14 @@ public void setMirthVersion(String mirthVersion) {
this.mirthVersion = mirthVersion;
}
+ 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/ObjectXMLSerializer.java b/server/src/main/java/com/mirth/connect/model/converters/ObjectXMLSerializer.java
index d549f89cc3..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,
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..7e780997d8 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,26 @@
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.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.ControllerException;
+import com.mirth.connect.client.core.ExtensionDependencies;
import com.mirth.connect.client.core.PropertiesConfigurationUtil;
import com.mirth.connect.model.ConnectorMetaData;
import com.mirth.connect.model.MetaData;
@@ -55,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();
@@ -140,77 +147,100 @@ 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(metaData, 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;
}
+ }
- 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('.'));
+ /** Validates a complete inventory, including providers rejected by their own requirements. */
+ public Map getCompatibilityErrors(Collection metadata, Predicate enabled) throws ControllerException {
+ List candidates = new ArrayList<>();
+ Map statuses = new HashMap<>();
+ Map errors = new LinkedHashMap<>();
+ for (MetaData extension : metadata) {
+ try {
+ statuses.computeIfAbsent(extension.getName(), enabled::test);
+ candidates.add(extension);
+ } catch (Exception e) {
+ errors.put(extension, "Could not read extension status: " + e.getMessage());
+ }
+ }
+ try {
+ 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);
}
+ return errors;
+ }
- for (int i = 0; i < extensionMirthVersions.length; i++) {
- if (extensionMirthVersions[i].trim().equals(serverMirthVersion)) {
- return true;
+ /** 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;
+ }
- return false;
+ 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) {
@@ -235,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 77b02c1874..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,19 +9,18 @@
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.InvalidPathException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collection;
-import java.util.Enumeration;
+import java.util.Collections;
import java.util.HashMap;
-import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
@@ -31,16 +30,11 @@
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 com.mirth.connect.client.core.BrandingConstants;
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;
@@ -79,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)
@@ -100,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;
@@ -121,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);
@@ -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,114 @@ public void updatePluginProperties(String name, Properties properties) {
}
@Override
- public InstallationResult extractExtension(InputStream inputStream) {
- Throwable cause = null;
- Set metaDataSet = new HashSet();
-
- File installTempDir = new File(ExtensionController.getExtensionsPath(), "install_temp");
+ public synchronized InstallationResult extractExtension(InputStream inputStream) {
+ 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);
+ }
+ }
+ }
+ 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(result.cause(), result.metadata());
+ }
- if (!installTempDir.exists()) {
- installTempDir.mkdir();
+ private Map> getPlannedExtensions() throws ControllerException {
+ File root = extensionsRoot;
+ Map> inventory = extensionLoader.readExtensionMetaData(root);
+ File uninstall = new File(root, EXTENSIONS_UNINSTALL_FILE);
+ try {
+ 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);
}
+ }
- File tempFile = null;
- FileOutputStream tempFileOutputStream = null;
- ZipFile zipFile = null;
-
+ 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 {
- /*
- * 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);
+ Path relative = Paths.get(path);
+ for (Path part : relative) {
+ if (part.toString().equals("..")) {
+ throw new ControllerException("Invalid extension package path: " + path);
+ }
}
-
- // 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 extends ZipEntry> 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);
-
- 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));
+ relative = relative.normalize();
+ if (relative.isAbsolute() || relative.getNameCount() != 1 || relative.toString().isEmpty()
+ || ExtensionInstaller.isReservedPath(relative.toString())) {
+ throw new ControllerException("Invalid extension package path: " + path);
+ }
+ String name = relative.toString();
+ File root = extensionsRoot;
+ 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 (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 (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();
}
-
- // delete the temp file since it is no longer needed
- FileUtils.deleteQuietly(tempFile);
}
-
- return new InstallationResult(cause, metaDataSet);
+ return null;
}
/**
@@ -513,7 +571,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(extensionsRoot, "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()) {
@@ -567,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 {
@@ -581,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 {
@@ -674,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));
}
}
@@ -691,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()) {
@@ -726,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 extends ZipEntry> 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 extends ZipEntry> 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 33458f2e08..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
@@ -19,7 +19,7 @@
import java.util.Collection;
import java.util.List;
import java.util.Properties;
-import java.util.jar.JarFile;
+import java.util.function.Predicate;
import javax.xml.parsers.DocumentBuilderFactory;
@@ -31,7 +31,7 @@
import org.apache.commons.io.filefilter.WildcardFileFilter;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
-import org.w3c.dom.NodeList;
+import org.w3c.dom.Node;
import com.mirth.connect.server.extprops.ExtensionStatuses;
import com.mirth.connect.server.extprops.LoggerWrapper;
@@ -49,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));
@@ -100,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();
@@ -115,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);
- }
}
}
@@ -228,77 +213,66 @@ private static void addManifestToClasspath(ManifestEntry[] manifestEntries, List
}
}
- private static void addExtensionsToClasspath(List urls, String currentVersion) 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);
- 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.getElementsByTagName("mirthVersion").item(0).getTextContent(), 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());
- }
- }
+ File[] directories = extensionPath.listFiles(File::isDirectory);
+ if (directories == null) {
+ logger.warn("no extensions found");
+ return;
+ }
+ 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 = getExtensionName(root);
+ if (name == null || name.trim().isEmpty()) {
+ throw new IllegalArgumentException("Extension metadata must declare a name");
+ }
+ if (!enabled.test(name)) {
+ continue;
+ }
+ 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);
}
+ } catch (Exception e) {
+ logger.error("failed to parse extension metadata: " + extensionFile.getAbsolutePath(), e);
}
}
- } else {
- logger.warn("no extensions found");
}
}
- 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('.'));
- }
-
- for (int i = 0; i < extensionMirthVersions.length; i++) {
- if (extensionMirthVersions[i].trim().equals(currentVersion)) {
- return true;
- }
+ private static String getExtensionName(Element metadata) {
+ for (Node child = metadata.getFirstChild(); child != null; child = child.getNextSibling()) {
+ if (child instanceof Element && "name".equals(child.getNodeName())) {
+ return child.getTextContent();
}
}
-
- 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/client/core/ExtensionDependenciesTest.java b/server/src/test/java/com/mirth/connect/client/core/ExtensionDependenciesTest.java
new file mode 100644
index 0000000000..682971290e
--- /dev/null
+++ b/server/src/test/java/com/mirth/connect/client/core/ExtensionDependenciesTest.java
@@ -0,0 +1,283 @@
+/*
+ * 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.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.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() {
+ 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,
+ 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() {
+ 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);
+ assertEquals(2, errors.size());
+ assertTrue(errors.get(empty).contains("engine release"));
+ assertTrue(errors.get(consumer).contains("engine release"));
+ 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"));
+ }
+
+ @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
+ @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() {
+ 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));
+ 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() {
+ 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"));
+ MetaData connector = descriptor("Provider", false, "1.0.0", null, "1.0.0", true);
+ assertTrue(validate(consumer, connector).get(consumer).contains("not installed"));
+ MetaData disabled = descriptor("Provider", true, "1.0.0", null, "1.0.0", false);
+ assertTrue(validate(consumer, disabled).get(consumer).contains("disabled"));
+
+ 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() {
+ MetaData consumer = plugin("Consumer", pluginRequirement("First", "1.0.0"),
+ pluginRequirement("Second", "2.0.0"));
+ 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"));
+ }
+
+ @Test
+ public void duplicatePluginNamesRejectEveryProviderAndTheirConsumers() {
+ 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"));
+ assertTrue(errors.get(consumer).contains("ambiguous"));
+ }
+
+ @Test
+ public void disabledConsumersKeepDeclarationAndEngineChecksButMayHaveMissingPlugins() {
+ MetaData disabled = descriptor("Disabled", true, "1.0.0", null, "1.0.0", false,
+ pluginRequirement("Missing", "1.0.0"));
+ assertTrue(validate(disabled).isEmpty());
+ 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"));
+ 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() {
+ 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);
+ 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() {
+ 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());
+ 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() {
+ 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);
+ assertTrue(errors.get(first).contains("Second"));
+ assertTrue(errors.get(second).contains("Missing"));
+
+ 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 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(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, 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) {
+ return new ExtensionDependency(type, name, version);
+ }
+
+ private static ExtensionDependency pluginRequirement(String name, String version) {
+ return requirement("plugin", name, version);
+ }
+
+ private MetaData plugin(String name, ExtensionDependency... dependencies) {
+ return descriptor(name, true, "1.0.0", null, "1.0.0", true, dependencies);
+ }
+
+ private MetaData versionedPlugin(String name, String version) {
+ return descriptor(name, true, version, null, "1.0.0", true);
+ }
+
+ private MetaData descriptor(String name, boolean plugin, String pluginVersion, String mirthVersion,
+ String minApiVersion, boolean enabled, ExtensionDependency... dependencies) {
+ 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 Map validate(MetaData... extensions) {
+ return ExtensionDependencies.validate(Arrays.asList(extensions), SERVER_VERSION,
+ name -> !disabledNames.contains(name));
+ }
+
+ 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
new file mode 100644
index 0000000000..bd206efdda
--- /dev/null
+++ b/server/src/test/java/com/mirth/connect/model/ExtensionMetaDataTest.java
@@ -0,0 +1,71 @@
+/*
+ * 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.assertNull;
+import static org.junit.Assert.assertNotNull;
+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.client.core.ExtensionDependencies;
+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 absentEmptyAndNullListsRetainLegacyMatching() {
+ for (String root : new String[] { "pluginMetaData", "connectorMetaData" }) {
+ for (String dependencies : new String[] { "", "", "" }) {
+ String xml = "<" + root + ">4.6.0" + dependencies + "" + root + ">";
+ 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 malformedRequirementsCannotFallBackToMatchingRelease() {
+ for (String root : new String[] { "pluginMetaData", "connectorMetaData" }) {
+ for (String dependency : new String[] {
+ "",
+ "",
+ "",
+ "",
+ "", "not a dependency" }) {
+ String xml = "<" + root + ">4.6.0"
+ + dependency + "" + root + ">";
+ 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" : "") + "" + root + ">";
+ }
+
+ 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
new file mode 100644
index 0000000000..5c5c60b8ae
--- /dev/null
+++ b/server/src/test/java/com/mirth/connect/server/controllers/ExtensionDependencyInstallationTest.java
@@ -0,0 +1,438 @@
+/*
+ * 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.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.spy;
+import static org.mockito.Mockito.when;
+
+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.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.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 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;
+
+/** Exercises installation and administrative actions without an engine or database. */
+public class ExtensionDependencyInstallationTest {
+ @Rule
+ public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ private final Map enabled = new HashMap<>();
+ private Path extensions;
+ private DefaultExtensionController controller;
+ private ExtensionStatuses statuses;
+ private ExtensionLoader loader;
+ private ObjectXMLSerializer serializer;
+
+ @Before
+ public void setUp() throws Exception {
+ extensions = temporaryFolder.newFolder("extensions").toPath();
+ 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());
+ loader = spy(new ExtensionLoader(serializer) {
+ @Override
+ protected String getServerVersion() {
+ return "4.5.2.123";
+ }
+ });
+ doReturn(Collections.emptyMap()).when(loader).getPluginMetaData();
+ createController();
+ }
+
+ 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"));
+ }
+ 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 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);
+ 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 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(" 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 uninstallQueuesSchemaCleanupForTheLoadedPlugin() throws Exception {
+ Map 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"));
+ 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"));
+ 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")));
+ doCallRealMethod().when(loader).getCompatibilityErrors(any(), any());
+ 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 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));
+ } 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")));
+ }
+
+ 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/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/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;
+ }
+}