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 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 entries = zip.entries(); + while (entries.hasMoreElements()) { + extractZipEntry(entries.nextElement(), payload, zip); + } + } + stagePackages(payload, staging, work); + } catch (Exception e) { + cause = e instanceof ControllerException || e instanceof VersionMismatchException ? e + : new ControllerException("Error extracting extension. " + e, e); + } finally { + // Preserve the last copy if rollback failed; the exception reports its location. + if (work != null && !new File(work, "backup").exists()) { + FileUtils.deleteQuietly(work); + } + } + return new Result(cause, metadata); + } + + private Map> readArchive(ZipFile zip, Set parsed) throws IOException { + Map> incoming = new LinkedHashMap<>(); + Set entryNames = new HashSet<>(); + Map packageNames = new HashMap<>(); + Enumeration entries = zip.entries(); + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + String name = entry.getName(); + String[] parts = name.split("/"); + if (!entryNames.add(name.toLowerCase(Locale.ROOT)) || parts.length == 0 || parts[0].isEmpty() + || name.contains("\\") || name.startsWith("/") || isReservedPath(parts[0]) + || (!entry.isDirectory() && parts.length < 2)) { + throw new ZipException("Invalid extension archive entry: " + name); + } + for (String part : parts) { + if (part.equals(".") || part.equals("..") || part.isEmpty()) { + throw new ZipException("Invalid extension archive entry: " + name); + } + } + String previous = packageNames.putIfAbsent(parts[0].toLowerCase(Locale.ROOT), parts[0]); + if (previous != null && !previous.equals(parts[0])) { + throw new ZipException("Package paths must not differ only by case: " + parts[0]); + } + List metadata = incoming.computeIfAbsent(parts[0], key -> new ArrayList<>()); + if (!entry.isDirectory() && ExtensionLoader.isMetaDataFile(parts[parts.length - 1])) { + if (parts.length != 2) { + throw new ZipException("Extension metadata must be directly inside its package: " + name); + } + try (InputStream input = zip.getInputStream(entry)) { + MetaData extension = serializer.deserialize(IOUtils.toString(input), MetaData.class); + if (!(extension instanceof PluginMetaData || extension instanceof ConnectorMetaData) + || StringUtils.isBlank(extension.getName())) { + throw new ZipException("Expected named plugin or connector metadata: " + name); + } + if (!parts[0].equals(extension.getPath())) { + throw new ZipException("Metadata path must match its package directory: " + name); + } + metadata.add(extension); + parsed.add(extension); + } + } + } + return incoming; + } + + static boolean isReservedPath(String path) { + path = path.toLowerCase(Locale.ROOT); + return path.equals("install_temp") || path.startsWith(".install-") + || path.equals(ExtensionController.EXTENSIONS_UNINSTALL_FILE) + || path.equals(ExtensionController.EXTENSIONS_UNINSTALL_PROPERTIES_FILE.toLowerCase(Locale.ROOT)) + || path.equals(ExtensionController.EXTENSIONS_UNINSTALL_SCRIPTS_FILE.toLowerCase(Locale.ROOT)); + } + + static void extractZipEntry(ZipEntry entry, File destination, ZipFile zip) throws IOException { + File file = new File(destination, entry.getName()); + if (!file.getCanonicalPath().startsWith(destination.getCanonicalPath() + File.separator)) { + throw new ZipException("Zip file is attempting to traverse out of base directory"); + } + if (entry.isDirectory()) { + FileUtils.forceMkdir(file); + } else { + FileUtils.forceMkdirParent(file); + try (InputStream input = zip.getInputStream(entry)) { + Files.copy(input, file.toPath()); + } + } + } + + /** Replace whole packages so retries cannot retain descriptors from an older archive. */ + private void stagePackages(File payload, File staging, File work) throws IOException { + File backup = new File(work, "backup"); + FileUtils.forceMkdir(backup); + List staged = new ArrayList<>(); + try { + for (File source : payload.listFiles()) { + File target = new File(staging, source.getName()); + if (target.exists()) { + Files.move(target.toPath(), new File(backup, source.getName()).toPath()); + } + Files.move(source.toPath(), target.toPath()); + staged.add(target); + } + } catch (IOException e) { + try { + for (File target : staged) { + FileUtils.deleteDirectory(target); + } + for (File original : backup.listFiles()) { + Files.move(original.toPath(), new File(staging, original.getName()).toPath()); + } + FileUtils.deleteDirectory(backup); + } catch (IOException rollback) { + e.addSuppressed(rollback); + throw new IOException("Could not restore pending extensions; recovery files retained at " + backup, e); + } + throw e; + } + if (!FileUtils.deleteQuietly(backup)) { + LogManager.getLogger(ExtensionInstaller.class).warn("Extensions staged successfully, but old staging files could not be removed: {}", backup); + } + } +} diff --git a/server/src/main/java/com/mirth/connect/server/launcher/MirthLauncher.java b/server/src/main/java/com/mirth/connect/server/launcher/MirthLauncher.java index 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 + ""; + 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 + ""; + MetaData metadata = serializer.deserialize(xml, MetaData.class); + assertNotNull(xml, ExtensionDependencies.getEngineError(metadata, "4.6.0")); + } + } + } +} diff --git a/server/src/test/java/com/mirth/connect/server/ExtensionLoaderTest.java b/server/src/test/java/com/mirth/connect/server/ExtensionLoaderTest.java new file mode 100644 index 0000000000..05d3b23808 --- /dev/null +++ b/server/src/test/java/com/mirth/connect/server/ExtensionLoaderTest.java @@ -0,0 +1,166 @@ +/* + * Copyright (c) Open Integration Engine contributors. + * Licensed under the Mozilla Public License 2.0. + */ + +package com.mirth.connect.server; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.io.FileNotFoundException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; + +import com.mirth.connect.client.core.ControllerException; +import com.mirth.connect.model.MetaData; +import com.mirth.connect.model.PluginMetaData; +import com.mirth.connect.model.converters.ObjectXMLSerializer; +import com.mirth.connect.server.extprops.ExtensionStatuses; +import com.mirth.connect.server.tools.ClassPathResource; + +public class ExtensionLoaderTest { + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + private final ObjectXMLSerializer serializer = new ObjectXMLSerializer(getClass().getClassLoader()); + private final ExtensionLoader loader = new ExtensionLoader(serializer) { + @Override + protected String getServerVersion() { + return "4.6.0.123"; + } + }; + + @Test + public void validatesCanonicalPluginAndConnectorMetadata() throws Exception { + for (String root : List.of("pluginMetaData", "connectorMetaData")) { + for (String version : List.of("1.0.0", "1.0.1", "2.0.0", "", "invalid")) { + MetaData metadata = parse(root, "Example", engine(version)); + assertEquals(version, version.equals("1.0.0"), loader.isExtensionCompatible(metadata)); + assertEquals(version, version.equals("1.0.0"), + loader.getCompatibilityErrors(List.of(metadata), name -> true).isEmpty()); + } + MetaData legacy = parse(root, "Legacy", ""); + assertTrue(loader.isExtensionCompatible(legacy)); + legacy.setMirthVersion("4.5.2"); + assertFalse(loader.isExtensionCompatible(legacy)); + } + } + + @Test + public void invalidDependenciesAreIsolatedFromUnrelatedMetadata() throws Exception { + for (String invalid : List.of("", "not a dependency", + "")) { + MetaData broken = parse("pluginMetaData", "Broken", invalid); + MetaData consumer = parse("pluginMetaData", "Consumer", engine("1.0.0") + requires("Broken")); + MetaData good = parse("pluginMetaData", "Good", engine("1.0.0")); + assertEquals(Set.of("Broken", "Consumer"), + names(loader.getCompatibilityErrors(List.of(broken, consumer, good), name -> true))); + } + } + + @Test + public void providerIdentityUsesTheDeserializedType() throws Exception { + MetaData provider = serializer.deserialize(xml("pluginMetaData", "Provider", engine("1.0.0")) + .replace("", ""), MetaData.class); + MetaData consumer = parse("pluginMetaData", "Consumer", engine("1.0.0") + requires("Provider")); + assertFalse(provider instanceof PluginMetaData); + assertEquals(Set.of("Consumer"), names(loader.getCompatibilityErrors(List.of(provider, consumer), name -> true))); + } + + @Test + public void statusFailureRejectsAffectedProviderAndConsumersOnly() throws Exception { + MetaData broken = parse("pluginMetaData", "Broken", engine("1.0.0")); + MetaData consumer = parse("pluginMetaData", "Consumer", engine("1.0.0") + requires("Broken")); + MetaData good = parse("pluginMetaData", "Good", engine("1.0.0")); + assertEquals(Set.of("Broken", "Consumer"), names(loader.getCompatibilityErrors( + List.of(broken, consumer, good), name -> { + if (name.equals("Broken")) { + throw new IllegalStateException("Unavailable status"); + } + return true; + }))); + } + + @Test + public void unavailableVersionFailsValidation() throws Exception { + ExtensionLoader unavailable = new ExtensionLoader(serializer) { + @Override + protected String getServerVersion() throws FileNotFoundException { + throw new FileNotFoundException("Unavailable release metadata"); + } + }; + MetaData metadata = parse("pluginMetaData", "Example", engine("1.0.0")); + assertFalse(unavailable.isExtensionCompatible(metadata)); + assertThrows(ControllerException.class, () -> unavailable.getCompatibilityErrors(List.of(metadata), name -> true)); + } + + @Test + public void startupExposesOnlyAcceptedMetadataAndIsolatesUnreadableDescriptors() throws Exception { + Path root = temporary.newFolder("extensions").toPath(); + write(root, "good/plugin.xml", xml("pluginMetaData", "Good", engine("1.0.0"))); + write(root, "provider/plugin.xml", xml("pluginMetaData", "Provider", engine("2.0.0"))); + write(root, "consumer/plugin.xml", xml("pluginMetaData", "Consumer", engine("1.0.0") + requires("Provider"))); + write(root, "connector/SOURCE.XML", xml("connectorMetaData", "Connector", engine("1.0.0") + requires("Good"))); + write(root, "broken/plugin.xml", xml("pluginMetaData", "Broken", engine("1.0.0"))); + write(root, "null/plugin.xml", ""); + write(root, "foreign/plugin.xml", "not metadata"); + write(root, "nameless/plugin.xml", xml("pluginMetaData", " ", engine("1.0.0"))); + write(root, "install_temp/plugin.xml", xml("pluginMetaData", "Pending", engine("1.0.0"))); + write(root, ".install-work/plugin.xml", xml("pluginMetaData", "Incomplete", engine("1.0.0"))); + try (MockedStatic paths = mockStatic(ClassPathResource.class); + MockedStatic statuses = mockStatic(ExtensionStatuses.class)) { + paths.when(() -> ClassPathResource.getResourceURI("extensions")).thenReturn(root.toUri()); + ExtensionStatuses status = mock(ExtensionStatuses.class); + when(status.isEnabled(org.mockito.ArgumentMatchers.anyString())).thenReturn(true); + when(status.isEnabled("Broken")).thenThrow(new IllegalStateException("Unavailable status")); + statuses.when(ExtensionStatuses::getInstance).thenReturn(status); + assertEquals(Set.of("Good"), loader.getPluginMetaData().keySet()); + assertEquals(Set.of("Connector"), loader.getConnectorMetaData().keySet()); + assertEquals(Set.of("example"), loader.getConnectorProtocols().keySet()); + assertEquals(Set.of("Provider", "Consumer", "Broken"), loader.getInvalidMetaData().keySet()); + } + } + + private void write(Path root, String path, String xml) throws Exception { + Path file = root.resolve(path); + Files.createDirectories(file.getParent()); + Files.writeString(file, xml); + } + + private Set names(Map errors) { + return errors.keySet().stream().map(MetaData::getName).collect(Collectors.toSet()); + } + + private MetaData parse(String root, String name, String dependencies) { + return serializer.deserialize(xml(root, name, dependencies), MetaData.class); + } + + private String xml(String root, String name, String dependencies) { + return "<" + root + ">" + name + "1.0.0" + + "4.6.0" + dependencies + "" + + (root.equals("connectorMetaData") ? "example" : "") + ""; + } + + private String engine(String version) { + return ""; + } + + private String requires(String name) { + return ""; + } +} diff --git a/server/src/test/java/com/mirth/connect/server/controllers/DefaultExtensionControllerTest.java b/server/src/test/java/com/mirth/connect/server/controllers/DefaultExtensionControllerTest.java index 984607dcdf..80c908ade5 100644 --- a/server/src/test/java/com/mirth/connect/server/controllers/DefaultExtensionControllerTest.java +++ b/server/src/test/java/com/mirth/connect/server/controllers/DefaultExtensionControllerTest.java @@ -12,64 +12,45 @@ import static org.junit.Assert.assertTrue; import java.io.File; +import java.nio.file.Files; +import java.nio.charset.StandardCharsets; import java.util.zip.ZipEntry; import java.util.zip.ZipException; import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; -import org.junit.After; -import org.junit.Before; +import org.junit.Rule; import org.junit.Test; - -import com.mirth.connect.util.ZipTestUtils; +import org.junit.rules.TemporaryFolder; public class DefaultExtensionControllerTest { + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); @Test(expected = ZipException.class) public void testExtractZipEntryZipSlipWithRelativePath() throws Exception { - DefaultExtensionController extensionController = new DefaultExtensionController(); - - File installTempDir = new File("tests/zipextraction"); - ZipEntry entry = new ZipEntry("../ZipSlip.txt"); - ZipFile zipFile = createTempZipFile("ZipSlip.txt"); - - extensionController.extractZipEntry(entry, installTempDir, zipFile); + File destination = temporaryFolder.newFolder("extraction"); + try (ZipFile zip = createTempZipFile("ZipSlip.txt")) { + ExtensionInstaller.extractZipEntry(new ZipEntry("../ZipSlip.txt"), destination, zip); + } } @Test public void testExtractZipEntryValidPath() throws Exception { - File installTempDir = new File("tests/zipextraction/"); - - DefaultExtensionController extensionController = new DefaultExtensionController(); - - ZipEntry entry = new ZipEntry("good.txt"); - ZipFile zipFile = createTempZipFile("good.txt"); - extensionController.extractZipEntry(entry, installTempDir, zipFile); - - File extractedFile = new File("tests/zipextraction/", "good.txt"); - assertTrue(extractedFile.exists()); - } - - @Before - public void createTestFolder() { - File installTempDir = new File("tests/zipextraction/"); - if (!installTempDir.exists()) { - installTempDir.mkdir(); - } else { - cleanupTestFolder(); - } - } - - @After - public void cleanupTestFolder() { - File tempDir = new File("tests/zipextraction/"); - if (tempDir.exists()) { - for (File file : tempDir.listFiles()) { - file.delete(); - } + File destination = temporaryFolder.newFolder("extraction"); + try (ZipFile zip = createTempZipFile("good.txt")) { + ExtensionInstaller.extractZipEntry(new ZipEntry("good.txt"), destination, zip); } + assertTrue(new File(destination, "good.txt").exists()); } private ZipFile createTempZipFile(String fileName) throws Exception { - return new ZipFile(ZipTestUtils.createTempZipFile(fileName)); + File archive = temporaryFolder.newFile("archive.zip"); + try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(archive.toPath()))) { + zip.putNextEntry(new ZipEntry(fileName)); + zip.write("file contents".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + return new ZipFile(archive); } -} \ No newline at end of file +} diff --git a/server/src/test/java/com/mirth/connect/server/controllers/ExtensionDependencyInstallationTest.java b/server/src/test/java/com/mirth/connect/server/controllers/ExtensionDependencyInstallationTest.java 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; + } +}