diff --git a/server/conf/mirth.properties b/server/conf/mirth.properties index cbc3a21cec..96157d3695 100644 --- a/server/conf/mirth.properties +++ b/server/conf/mirth.properties @@ -1,4 +1,8 @@ # Mirth Connect configuration file +# +# Put local customizations in files under conf/mirth.properties.d instead of +# editing this file, so a new release's mirth.properties can be dropped in +# without hand-merging. See conf/mirth.properties.d/mirth.properties.example. # directories dir.appdata = appdata diff --git a/server/conf/mirth.properties.d/mirth.properties.example b/server/conf/mirth.properties.d/mirth.properties.example new file mode 100644 index 0000000000..9dcb9b5181 --- /dev/null +++ b/server/conf/mirth.properties.d/mirth.properties.example @@ -0,0 +1,21 @@ +# Copy this file to a name ending in .properties (for example 10-local.properties) +# to activate it. Files in this directory named *.properties are loaded on top of +# conf/mirth.properties at startup, in lexical filename order: later files +# override earlier ones, and any file here overrides mirth.properties itself. +# +# Keeping customizations here instead of in mirth.properties means an upgrade +# can replace mirth.properties without hand-merging your changes back in. +# +# Exception: database.password and database-readonly.password are encrypted in +# place in mirth.properties; a value here stays plaintext. keystore.storepass +# and keystore.keypass are auto-generated on first boot only when BOTH are +# still the shipped defaults - overriding just one here leaves the other at +# its published default. Leave these four in mirth.properties. +# +# Files are parsed with java.util.Properties semantics. If a key appears more +# than once, the last occurrence wins; there are no multi-valued properties. For +# list properties such as https.client.protocols, use a single comma-separated +# value. + +# http.port = 8081 +# https.port = 8444 diff --git a/server/src/main/java/com/mirth/connect/server/Mirth.java b/server/src/main/java/com/mirth/connect/server/Mirth.java index 5e8cacfcf0..ae37b1dba7 100644 --- a/server/src/main/java/com/mirth/connect/server/Mirth.java +++ b/server/src/main/java/com/mirth/connect/server/Mirth.java @@ -182,15 +182,16 @@ public void run() { * @return true if the resources required by the server have been successfully loaded */ public boolean initResources() { - InputStream mirthPropertiesStream = null; - - try { - mirthPropertiesStream = ResourceUtil.getResourceStream(this.getClass(), "mirth.properties"); - mirthProperties = PropertiesConfigurationUtil.create(mirthPropertiesStream); - } catch (Exception e) { - logger.error("could not load mirth.properties", e); - } finally { - IOUtils.closeQuietly(mirthPropertiesStream); + // Read the shared, drop-in-merged copy the configuration controller already loaded, rather + // than reloading and re-merging mirth.properties here. + mirthProperties = configurationController.getPropertiesConfiguration(); + + // Refuse to start if configuration could not be loaded cleanly (e.g. an unreadable + // mirth.properties.d drop-in file) rather than run with a silently reverted configuration. + String configurationLoadError = configurationController.getConfigurationLoadError(); + if (configurationLoadError != null) { + logger.error("Refusing to start: " + configurationLoadError); + return false; } InputStream versionPropertiesStream = null; @@ -221,7 +222,10 @@ public void startup() { configurationController.initializeSecuritySettings(); configurationController.initializeDatabaseSettings(); - // Refresh the in-memory config in case the configuration controller changed it + // Pull in any settings the controller changed during initialization (e.g. generated keystore + // passwords). With the default controller mirthProperties is already the controller's own + // configuration, so this is a no-op; a controller that hands out a separate configuration is + // refreshed here. configurationController.updatePropertiesConfiguration(mirthProperties); try { diff --git a/server/src/main/java/com/mirth/connect/server/controllers/ConfigurationController.java b/server/src/main/java/com/mirth/connect/server/controllers/ConfigurationController.java index e4a9ce88c3..2a9d06ccbe 100644 --- a/server/src/main/java/com/mirth/connect/server/controllers/ConfigurationController.java +++ b/server/src/main/java/com/mirth/connect/server/controllers/ConfigurationController.java @@ -9,6 +9,7 @@ package com.mirth.connect.server.controllers; +import java.io.InputStream; import java.util.Calendar; import java.util.List; import java.util.Locale; @@ -21,6 +22,7 @@ import com.mirth.commons.encryption.Digester; import com.mirth.commons.encryption.Encryptor; import com.mirth.connect.client.core.ControllerException; +import com.mirth.connect.client.core.PropertiesConfigurationUtil; import com.mirth.connect.model.ChannelDependency; import com.mirth.connect.model.ChannelMetadata; import com.mirth.connect.model.ChannelTag; @@ -32,6 +34,8 @@ import com.mirth.connect.model.ServerConfiguration; import com.mirth.connect.model.ServerSettings; import com.mirth.connect.model.UpdateSettings; +import com.mirth.connect.server.extprops.DropInProperties; +import com.mirth.connect.server.util.ResourceUtil; import com.mirth.connect.util.ConfigurationProperty; import com.mirth.connect.util.ConnectionTestResponse; @@ -79,6 +83,32 @@ public static ConfigurationController getInstance() { */ public abstract void updatePropertiesConfiguration(PropertiesConfiguration config); + /** + * Returns the loaded mirth.properties configuration, with any conf/mirth.properties.d drop-in + * overrides applied. Server-side consumers should read from this shared copy rather than loading + * and merging the file themselves. The returned configuration is owned by the controller; + * consumers must treat it as read-only, as the controller writes to it during startup (for + * example, when it generates keystore passwords). This default loads on demand; + * DefaultConfigurationController overrides it to return the copy it already holds. + */ + public PropertiesConfiguration getPropertiesConfiguration() { + try (InputStream is = ResourceUtil.getResourceStream(getClass(), "mirth.properties")) { + return DropInProperties.overlay(PropertiesConfigurationUtil.create(is), ResourceUtil.getMirthPropertiesDropInDirectory()); + } catch (Exception e) { + return PropertiesConfigurationUtil.create(); + } + } + + /** + * Returns a message describing a fatal configuration problem that must prevent the server from + * starting, such as an unreadable conf/mirth.properties.d drop-in file, or null when + * configuration loaded cleanly. The default reports no error; DefaultConfigurationController + * overrides it. + */ + public String getConfigurationLoadError() { + return null; + } + /** * Returns the default encryptor. * diff --git a/server/src/main/java/com/mirth/connect/server/controllers/DefaultConfigurationController.java b/server/src/main/java/com/mirth/connect/server/controllers/DefaultConfigurationController.java index bfee2eddbf..fa1b5f85d5 100644 --- a/server/src/main/java/com/mirth/connect/server/controllers/DefaultConfigurationController.java +++ b/server/src/main/java/com/mirth/connect/server/controllers/DefaultConfigurationController.java @@ -118,6 +118,7 @@ import com.mirth.connect.model.converters.ObjectXMLSerializer; import com.mirth.connect.plugins.directoryresource.DirectoryResourceProperties; import com.mirth.connect.server.ExtensionLoader; +import com.mirth.connect.server.extprops.DropInProperties; import com.mirth.connect.server.mybatis.KeyValuePair; import com.mirth.connect.server.tools.ClassPathResource; import com.mirth.connect.server.util.DatabaseUtil; @@ -166,6 +167,12 @@ public class DefaultConfigurationController extends ConfigurationController { private static PropertiesConfiguration versionConfig = PropertiesConfigurationUtil.create(); private static FileBasedConfigurationBuilder mirthConfigBuilder = PropertiesConfigurationUtil.createBuilder(); protected static PropertiesConfiguration mirthConfig = PropertiesConfigurationUtil.create(); + // The file-backed configuration that saveMirthConfig() writes. Kept separate from mirthConfig + // so that values from mirth.properties.d drop-in files are never baked into mirth.properties. + protected static PropertiesConfiguration mirthFileConfig = mirthConfig; + // Set when a mirth.properties.d drop-in file cannot be read, so the server refuses to start + // rather than run with a silently reverted configuration. Null when configuration loaded cleanly. + private static volatile String configurationLoadError; private static EncryptionSettings encryptionConfig; private static DatabaseSettings databaseConfig; private static String apiBypassword; @@ -229,15 +236,28 @@ public void initialize() { try { // Delimiter parsing disabled by default so getString() returns the whole property, even if there are commas mirthConfigBuilder = PropertiesConfigurationUtil.createBuilder(new File(ClassPathResource.getResourceURI("mirth.properties"))); - mirthConfig = mirthConfigBuilder.getConfiguration(); + mirthFileConfig = mirthConfigBuilder.getConfiguration(); + // Also assign the read view now so anything reading during migration sees real values + mirthConfig = mirthFileConfig; - MigrationController.getInstance().migrateConfiguration(mirthConfig); + MigrationController.getInstance().migrateConfiguration(mirthFileConfig); try { mirthConfigBuilder.save(); } catch (ConfigurationException e) { logger.error("Unable to update mirth.properties version during migration.", e); } + try { + mirthConfig = DropInProperties.overlay(mirthFileConfig, ResourceUtil.getMirthPropertiesDropInDirectory()); + } catch (ConfigurationException e) { + // Fail closed: a broken drop-in file must stop startup rather than silently reverting + // to the base configuration, which could serve weaker settings than the administrator + // intended. mirthConfig stays at the base config and getConfigurationLoadError() reports + // the problem so the server refuses to start. + configurationLoadError = "Unable to load conf/mirth.properties.d drop-in configuration: " + e.getMessage(); + logger.error(configurationLoadError, e); + } + // load the server version versionPropertiesStream = ResourceUtil.getResourceStream(this.getClass(), "version.properties"); versionConfig = PropertiesConfigurationUtil.create(versionPropertiesStream); @@ -1245,11 +1265,11 @@ public void initializeSecuritySettings() { */ if (Arrays.equals(keyStorePassword, DEFAULT_STOREPASS.toCharArray()) && Arrays.equals(keyPassword, DEFAULT_STOREPASS.toCharArray())) { String keyStorePasswordStr = generateNewPassword(); - mirthConfig.setProperty("keystore.storepass", keyStorePasswordStr); + setMirthConfigProperty("keystore.storepass", keyStorePasswordStr); keyStorePassword = keyStorePasswordStr.toCharArray(); String keyPasswordStr = generateNewPassword(); - mirthConfig.setProperty("keystore.keypass", keyPasswordStr); + setMirthConfigProperty("keystore.keypass", keyPasswordStr); keyPassword = keyPasswordStr.toCharArray(); saveMirthConfig(); @@ -1329,18 +1349,26 @@ private void updateDatabasePassword(Encryptor encryptor, String password, boolea if (!StringUtils.startsWith(encryptedPassword, "{")) { // Re-encrypt if still using the old-style format - mirthConfig.setProperty(readOnly ? DatabaseConstants.DATABASE_READONLY_PASSWORD : DatabaseConstants.DATABASE_PASSWORD, EncryptionSettings.ENCRYPTION_PREFIX + encryptor.encrypt(decryptedPassword)); + setMirthConfigProperty(readOnly ? DatabaseConstants.DATABASE_READONLY_PASSWORD : DatabaseConstants.DATABASE_PASSWORD, EncryptionSettings.ENCRYPTION_PREFIX + encryptor.encrypt(decryptedPassword)); saveMirthConfig(); } } else if (StringUtils.isNotBlank(password)) { // encrypt the password and write it back to the file String encryptedPassword = EncryptionSettings.ENCRYPTION_PREFIX + encryptor.encrypt(password); - mirthConfig.setProperty(readOnly ? DatabaseConstants.DATABASE_READONLY_PASSWORD : DatabaseConstants.DATABASE_PASSWORD, encryptedPassword); + setMirthConfigProperty(readOnly ? DatabaseConstants.DATABASE_READONLY_PASSWORD : DatabaseConstants.DATABASE_PASSWORD, encryptedPassword); saveMirthConfig(); } } + private void setMirthConfigProperty(String key, Object value) { + mirthConfig.setProperty(key, value); + + if (mirthFileConfig != mirthConfig) { + mirthFileConfig.setProperty(key, value); + } + } + private void saveMirthConfig() throws FileNotFoundException, ConfigurationException { /* * Save using a FileOutputStream so that the file will be saved to the proper location, even @@ -1350,7 +1378,7 @@ private void saveMirthConfig() throws FileNotFoundException, ConfigurationExcept OutputStream os = new FileOutputStream(new File(confDir, "mirth.properties")); try { - PropertiesConfigurationUtil.saveTo(mirthConfig, os); + PropertiesConfigurationUtil.saveTo(mirthFileConfig, os); } finally { ResourceUtil.closeResourceQuietly(os); } @@ -1438,6 +1466,16 @@ public void migrateKeystore() { } } + @Override + public PropertiesConfiguration getPropertiesConfiguration() { + return mirthConfig; + } + + @Override + public String getConfigurationLoadError() { + return configurationLoadError; + } + @Override public void updatePropertiesConfiguration(PropertiesConfiguration config) { config.copy(mirthConfig); diff --git a/server/src/main/java/com/mirth/connect/server/extprops/DropInProperties.java b/server/src/main/java/com/mirth/connect/server/extprops/DropInProperties.java new file mode 100644 index 0000000000..bc9b031525 --- /dev/null +++ b/server/src/main/java/com/mirth/connect/server/extprops/DropInProperties.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) Mirth Corporation. All rights reserved. + * + * http://www.mirthcorp.com + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.server.extprops; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Properties; + +import org.apache.commons.configuration2.ConfigurationUtils; +import org.apache.commons.configuration2.PropertiesConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; + +/** + * Applies drop-in override files from a mirth.properties.d directory on top of an already-loaded + * configuration. Files directly under the directory whose names end in ".properties" are applied + * in lexical filename order: later files override earlier ones, and all of them override the base + * configuration. Merge semantics come from java.util.Properties.load(), where the last occurrence + * of a key wins. + * + * This class lives in the extprops package because it is shared by the launcher jar and the server + * jar, and must not reference classes outside this package and the launcher's classpath. + */ +public class DropInProperties { + + /** + * Loads drop-in files into the given properties, overriding any existing values. Files that + * cannot be read are logged and skipped. + */ + public static void load(Properties properties, File dropInDir, LoggerWrapper logger) { + File[] files = listDropInFiles(dropInDir); + + if (files == null) { + if (isUnlistableDirectory(dropInDir)) { + logger.error("Unable to list drop-in properties directory: " + dropInDir.getPath()); + } + return; + } + + for (File file : files) { + try (InputStream is = new FileInputStream(file)) { + properties.load(is); + } catch (IOException | IllegalArgumentException e) { + // IllegalArgumentException covers a malformed \\uXXXX escape in Properties.load() + logger.error("Unable to read drop-in properties file: " + file.getPath(), e); + } + } + } + + /** + * Returns a configuration with any drop-in files applied on top of the given base + * configuration. The base configuration is never modified; it is returned as-is when there are + * no drop-in files. A drop-in file that cannot be read or parsed throws a ConfigurationException + * so that the caller can fail closed rather than start with a partially applied configuration. + */ + public static PropertiesConfiguration overlay(PropertiesConfiguration base, File dropInDir) throws ConfigurationException { + File[] files = listDropInFiles(dropInDir); + + if (files == null) { + if (isUnlistableDirectory(dropInDir)) { + throw new ConfigurationException("Unable to list drop-in properties directory: " + dropInDir.getPath()); + } + return base; + } + + if (files.length == 0) { + return base; + } + + Properties dropIns = new Properties(); + + for (File file : files) { + try (InputStream is = new FileInputStream(file)) { + dropIns.load(is); + } catch (IOException | IllegalArgumentException e) { + // IllegalArgumentException covers a malformed \\uXXXX escape in Properties.load(). + // A file that cannot be read or parsed is fatal here so a broken drop-in fails startup + // rather than silently reverting to the base configuration. + throw new ConfigurationException("Unable to read drop-in properties file: " + file.getPath(), e); + } + } + + PropertiesConfiguration merged = new PropertiesConfiguration(); + ConfigurationUtils.copy(base, merged); + + for (String key : dropIns.stringPropertyNames()) { + merged.setProperty(key, dropIns.getProperty(key)); + } + + return merged; + } + + private static File[] listDropInFiles(File dropInDir) { + File[] files = dropInDir == null ? null : dropInDir.listFiles(file -> file.isFile() && file.getName().endsWith(".properties")); + + if (files != null) { + Arrays.sort(files); + } + + return files; + } + + /** listFiles() returns null both for a missing directory and an unlistable one; only the latter deserves noise. */ + private static boolean isUnlistableDirectory(File dropInDir) { + return dropInDir != null && dropInDir.isDirectory(); + } +} diff --git a/server/src/main/java/com/mirth/connect/server/extprops/ExtensionStatuses.java b/server/src/main/java/com/mirth/connect/server/extprops/ExtensionStatuses.java index f205956e67..60148be8c1 100644 --- a/server/src/main/java/com/mirth/connect/server/extprops/ExtensionStatuses.java +++ b/server/src/main/java/com/mirth/connect/server/extprops/ExtensionStatuses.java @@ -55,6 +55,7 @@ private ExtensionStatuses() { } finally { IOUtils.closeQuietly(is); } + DropInProperties.load(mirthProperties, new File("./conf/mirth.properties.d"), logger); } catch (Exception e) { logger.error("Unable to read mirth.properties.", e); } 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..60aa34e513 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 @@ -33,6 +33,7 @@ import org.w3c.dom.Element; import org.w3c.dom.NodeList; +import com.mirth.connect.server.extprops.DropInProperties; import com.mirth.connect.server.extprops.ExtensionStatuses; import com.mirth.connect.server.extprops.LoggerWrapper; @@ -77,6 +78,7 @@ public static void main(String[] args) { try (FileInputStream inputStream = new FileInputStream(new File(MIRTH_PROPERTIES_FILE))) { mirthProperties.load(inputStream); + DropInProperties.load(mirthProperties, new File(MIRTH_PROPERTIES_FILE + ".d"), logger); includeCustomLib = mirthProperties.getProperty(PROPERTY_INCLUDE_CUSTOM_LIB); createAppdataDir(mirthProperties); } catch (Exception e) { diff --git a/server/src/main/java/com/mirth/connect/server/servlets/WebStartServlet.java b/server/src/main/java/com/mirth/connect/server/servlets/WebStartServlet.java index 8bb847055b..a4b3d2db37 100644 --- a/server/src/main/java/com/mirth/connect/server/servlets/WebStartServlet.java +++ b/server/src/main/java/com/mirth/connect/server/servlets/WebStartServlet.java @@ -359,16 +359,8 @@ private String getDigest(File directory, String filePath) throws Exception { } protected PropertiesConfiguration getMirthProperties() throws FileNotFoundException, ConfigurationException { - PropertiesConfiguration mirthProperties = PropertiesConfigurationUtil.create(); - - InputStream mirthPropsIs = null; - try { - mirthPropsIs = ResourceUtil.getResourceStream(getClass(), "mirth.properties"); - mirthProperties = PropertiesConfigurationUtil.create(mirthPropsIs); - } finally { - ResourceUtil.closeResourceQuietly(mirthPropsIs); - } - return mirthProperties; + // Read the shared, drop-in-merged copy rather than reloading the file on every request. + return configurationController.getPropertiesConfiguration(); } private String getContextPathProp(PropertiesConfiguration mirthProperties) { diff --git a/server/src/main/java/com/mirth/connect/server/util/ResourceUtil.java b/server/src/main/java/com/mirth/connect/server/util/ResourceUtil.java index 6bcf506e4c..e01a79624b 100644 --- a/server/src/main/java/com/mirth/connect/server/util/ResourceUtil.java +++ b/server/src/main/java/com/mirth/connect/server/util/ResourceUtil.java @@ -10,12 +10,31 @@ package com.mirth.connect.server.util; import java.io.Closeable; +import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; +import java.net.URI; + +import com.mirth.connect.server.tools.ClassPathResource; public class ResourceUtil { + + /** + * Returns the mirth.properties.d drop-in directory next to the mirth.properties resolved from + * the classpath, or null when mirth.properties does not resolve to a file on disk. + */ + public static File getMirthPropertiesDropInDirectory() { + URI uri = ClassPathResource.getResourceURI("mirth.properties"); + + if (uri != null && "file".equals(uri.getScheme())) { + return new File(new File(uri).getParentFile(), "mirth.properties.d"); + } + + return null; + } + /** * Returns a resource as a stream by checking: * diff --git a/server/src/main/java/com/mirth/connect/server/util/javascript/JavaScriptScopeUtil.java b/server/src/main/java/com/mirth/connect/server/util/javascript/JavaScriptScopeUtil.java index 3eb537eb9d..502ceb33e7 100644 --- a/server/src/main/java/com/mirth/connect/server/util/javascript/JavaScriptScopeUtil.java +++ b/server/src/main/java/com/mirth/connect/server/util/javascript/JavaScriptScopeUtil.java @@ -33,6 +33,8 @@ import com.mirth.connect.donkey.model.message.Message; import com.mirth.connect.donkey.model.message.RawMessage; import com.mirth.connect.server.controllers.ConfigurationController; +import com.mirth.connect.server.extprops.DropInProperties; +import com.mirth.connect.server.extprops.LoggerWrapper; import com.mirth.connect.server.transformers.InvalidTransformedDataException; import com.mirth.connect.server.userutil.AlertSender; import com.mirth.connect.server.userutil.Attachment; @@ -44,6 +46,7 @@ import com.mirth.connect.server.userutil.VMRouter; import com.mirth.connect.server.util.GlobalChannelVariableStoreFactory; import com.mirth.connect.server.util.GlobalVariableStore; +import com.mirth.connect.server.util.ResourceUtil; import com.mirth.connect.server.util.TemplateValueReplacer; import com.mirth.connect.userutil.ImmutableConnectorMessage; import com.mirth.connect.userutil.ImmutableMessage; @@ -62,6 +65,7 @@ public class JavaScriptScopeUtil { * it in interpretive mode. See MIRTH-1627 for more information. */ Properties properties = PropertyLoader.loadProperties("mirth"); + DropInProperties.load(properties, ResourceUtil.getMirthPropertiesDropInDirectory(), new LoggerWrapper(logger)); if (MapUtils.isNotEmpty(properties) && properties.containsKey("rhino.optimizationlevel")) { logger.debug("set Rhino context optimization level: " + rhinoOptimizationLevel); diff --git a/server/src/test/java/com/mirth/connect/server/extprops/DropInPropertiesTest.java b/server/src/test/java/com/mirth/connect/server/extprops/DropInPropertiesTest.java new file mode 100644 index 0000000000..97d7ce6db4 --- /dev/null +++ b/server/src/test/java/com/mirth/connect/server/extprops/DropInPropertiesTest.java @@ -0,0 +1,124 @@ +package com.mirth.connect.server.extprops; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Properties; + +import org.apache.commons.configuration2.PropertiesConfiguration; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class DropInPropertiesTest { + + @Rule + public TemporaryFolder tempFolder = new TemporaryFolder(); + + @Test + public void overlayReturnsBaseWhenDirIsNull() throws Exception { + PropertiesConfiguration base = new PropertiesConfiguration(); + assertSame(base, DropInProperties.overlay(base, null)); + } + + @Test + public void overlayReturnsBaseWhenDirIsMissing() throws Exception { + PropertiesConfiguration base = new PropertiesConfiguration(); + assertSame(base, DropInProperties.overlay(base, new File(tempFolder.getRoot(), "missing.d"))); + } + + @Test + public void overlayIgnoresNonPropertiesEntries() throws Exception { + File dir = tempFolder.newFolder(); + writeFile(dir, "README", "not a properties file"); + new File(dir, "subdir.properties").mkdir(); + + PropertiesConfiguration base = new PropertiesConfiguration(); + assertSame(base, DropInProperties.overlay(base, dir)); + } + + @Test + public void overlayOverridesAndAddsWithoutModifyingBase() throws Exception { + PropertiesConfiguration base = new PropertiesConfiguration(); + base.setProperty("a", "base"); + base.setProperty("b", "kept"); + + File dir = tempFolder.newFolder(); + writeFile(dir, "10-first.properties", "a = first\nc = added\n"); + writeFile(dir, "20-second.properties", "a = second\n"); + + PropertiesConfiguration merged = DropInProperties.overlay(base, dir); + + assertEquals("second", merged.getString("a")); + assertEquals("kept", merged.getString("b")); + assertEquals("added", merged.getString("c")); + assertEquals("base", base.getString("a")); + assertFalse(base.containsKey("c")); + } + + @Test + public void overlayResultIsIndependentOfBase() throws Exception { + PropertiesConfiguration base = new PropertiesConfiguration(); + base.setProperty("a", "base"); + + File dir = tempFolder.newFolder(); + writeFile(dir, "10-first.properties", "b = dropin\n"); + + PropertiesConfiguration merged = DropInProperties.overlay(base, dir); + + // Writing to the merged result (as the controller does when it re-encrypts a password or + // generates a keystore password) must not leak back into the base configuration that + // saveMirthConfig() writes out to mirth.properties. + merged.setProperty("a", "changed"); + merged.setProperty("c", "new"); + + assertEquals("base", base.getString("a")); + assertFalse(base.containsKey("c")); + } + + @Test + public void overlayKeepsCommaValuesWhole() throws Exception { + PropertiesConfiguration base = new PropertiesConfiguration(); + + File dir = tempFolder.newFolder(); + writeFile(dir, "10-list.properties", "https.client.protocols = TLSv1.3,TLSv1.2\n"); + + PropertiesConfiguration merged = DropInProperties.overlay(base, dir); + + assertEquals("TLSv1.3,TLSv1.2", merged.getString("https.client.protocols")); + } + + @Test + public void loadOverridesInLexicalOrder() throws Exception { + Properties properties = new Properties(); + properties.setProperty("a", "base"); + + File dir = tempFolder.newFolder(); + writeFile(dir, "20-second.properties", "a = second\n"); + writeFile(dir, "10-first.properties", "a = first\nb = added\n"); + + DropInProperties.load(properties, dir, new LoggerWrapper(null)); + + assertEquals("second", properties.getProperty("a")); + assertEquals("added", properties.getProperty("b")); + } + + @Test + public void loadIsNoOpWhenDirIsMissing() { + Properties properties = new Properties(); + properties.setProperty("a", "base"); + + DropInProperties.load(properties, new File(tempFolder.getRoot(), "missing.d"), new LoggerWrapper(null)); + + assertEquals("base", properties.getProperty("a")); + assertEquals(1, properties.size()); + } + + private void writeFile(File dir, String name, String content) throws Exception { + Files.write(new File(dir, name).toPath(), content.getBytes(StandardCharsets.ISO_8859_1)); + } +}