From 4acd41ec747dfb85917b322f1d866b64138531cc Mon Sep 17 00:00:00 2001 From: Connor Date: Mon, 22 Jun 2026 06:07:50 -0400 Subject: [PATCH 1/2] config :: support multiple configuration directories and update related methods --- .../java/emissary/command/BaseCommand.java | 44 ++++++++++++++++--- .../java/emissary/server/EmissaryServer.java | 3 +- src/main/java/emissary/server/api/Env.java | 2 +- .../emissary/command/ServerCommandIT.java | 24 ++++++++++ .../emissary/server/api/EmissaryApiTest.java | 1 + 5 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/main/java/emissary/command/BaseCommand.java b/src/main/java/emissary/command/BaseCommand.java index bca4906d74..970381a36f 100644 --- a/src/main/java/emissary/command/BaseCommand.java +++ b/src/main/java/emissary/command/BaseCommand.java @@ -23,7 +23,9 @@ import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; @Command(description = "Base Command") public abstract class BaseCommand implements EmissaryCommand { @@ -31,10 +33,10 @@ public abstract class BaseCommand implements EmissaryCommand { public static final String COMMAND_NAME = "BaseCommand"; - @Option(names = {"-c", "--config"}, description = "config dir, comma separated if multiple, defaults to /config", + @Option(names = {"-c", "--config"}, split = ",", description = "config dir, comma separated if multiple, defaults to /config", converter = PathExistsConverter.class) @Nullable - private Path config; + private List config; @Option(names = {"-b", "--projectBase"}, description = "defaults to PROJECT_BASE, errors if different\nDefault: ${DEFAULT-VALUE}", converter = ProjectBaseConverter.class) @@ -62,17 +64,45 @@ public abstract class BaseCommand implements EmissaryCommand { @Option(names = {"-q", "--quiet"}, description = "hide banner and non essential messages\nDefault: ${DEFAULT-VALUE}") private boolean quiet = false; - public Path getConfig() { + /** + * Get all configured config directories, in order. Defaults to a single {@code /config} dir when none were + * supplied on the command line. + * + * @return the ordered list of config directories + */ + public List getConfigDirs() { if (config == null) { - config = getProjectBase().toAbsolutePath().resolve("config"); - if (!Files.exists(config)) { - throw new IllegalArgumentException("Config dir not configured and " + config.toAbsolutePath() + " does not exist"); + Path defaultConfig = getProjectBase().toAbsolutePath().resolve("config"); + if (!Files.exists(defaultConfig)) { + throw new IllegalArgumentException("Config dir not configured and " + defaultConfig.toAbsolutePath() + " does not exist"); } + config = new ArrayList<>(List.of(defaultConfig)); } return config; } + /** + * Get the first/primary config directory. + * + * @return the first configured config directory + */ + public Path getConfig() { + return getConfigDirs().get(0); + } + + /** + * Get the configured config directories as the comma-separated, absolute-path string used for the + * {@value ConfigUtil#CONFIG_DIR_PROPERTY} system property. + * + * @return the config dirs joined by commas + */ + public String getConfigDirsProperty() { + return getConfigDirs().stream() + .map(p -> p.toAbsolutePath().toString()) + .collect(Collectors.joining(",")); + } + public Path getProjectBase() { return projectBase; } @@ -130,7 +160,7 @@ public void setupCommand() { public void setupConfig() { logInfo("{} is set to {} ", ConfigUtil.PROJECT_BASE_ENV, getProjectBase().toAbsolutePath().toString()); - setSystemProperty(ConfigUtil.CONFIG_DIR_PROPERTY, getConfig().toAbsolutePath().toString()); + setSystemProperty(ConfigUtil.CONFIG_DIR_PROPERTY, getConfigDirsProperty()); setSystemProperty(ConfigUtil.CONFIG_BIN_PROPERTY, getBinDir().toAbsolutePath().toString()); setSystemProperty(ConfigUtil.CONFIG_OUTPUT_ROOT_PROPERTY, getOutputDir().toAbsolutePath().toString()); logInfo("Emissary error dir set to {} ", getErrorDir().toAbsolutePath().toString()); diff --git a/src/main/java/emissary/server/EmissaryServer.java b/src/main/java/emissary/server/EmissaryServer.java index 1253700230..fc5c1ecf74 100644 --- a/src/main/java/emissary/server/EmissaryServer.java +++ b/src/main/java/emissary/server/EmissaryServer.java @@ -71,6 +71,7 @@ import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; +import java.util.Arrays; import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; @@ -725,7 +726,7 @@ protected void csrfFilter(ResourceConfig application) { private ContextHandler buildEmissaryHandler() throws EmissaryException { // must set these set or you are not an EmissaryNode String configDir = System.getProperty(ConfigUtil.CONFIG_DIR_PROPERTY, null); - if (configDir == null || !Files.exists(Path.of(configDir))) { + if (configDir == null || Arrays.stream(configDir.split(",")).anyMatch(d -> d.isEmpty() || !Files.exists(Path.of(d)))) { throw new EmissaryException("Config dir error. " + ConfigUtil.CONFIG_DIR_PROPERTY + " is " + configDir); } // set number of agents if it has been set diff --git a/src/main/java/emissary/server/api/Env.java b/src/main/java/emissary/server/api/Env.java index a119baecd0..b372febb4f 100644 --- a/src/main/java/emissary/server/api/Env.java +++ b/src/main/java/emissary/server/api/Env.java @@ -47,7 +47,7 @@ private MapResponseEntity getEnv() { try { EmissaryServer server = (EmissaryServer) Namespace.lookup(EMISSARY_SERVER); ServerCommand command = server.getServerCommand(); - entity.addKeyValue("CONFIG_DIR", command.getConfig().toAbsolutePath().toString()); + entity.addKeyValue("CONFIG_DIR", command.getConfigDirsProperty()); entity.addKeyValue("PROJECT_BASE", command.getProjectBase().toAbsolutePath().toString()); entity.addKeyValue("OUTPUT_ROOT", command.getOutputDir().toAbsolutePath().toString()); entity.addKeyValue("BIN_DIR", command.getBinDir().toAbsolutePath().toString()); diff --git a/src/test/java/emissary/command/ServerCommandIT.java b/src/test/java/emissary/command/ServerCommandIT.java index 0300d04273..854bb885d9 100644 --- a/src/test/java/emissary/command/ServerCommandIT.java +++ b/src/test/java/emissary/command/ServerCommandIT.java @@ -4,8 +4,10 @@ import emissary.test.core.junit5.UnitTest; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import java.nio.file.Path; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -26,6 +28,28 @@ void testGetConfigWithTrailingSlash() throws Exception { assertEquals(Path.of(PROJECT_BASE_SLASH + "/config"), cmd.getConfig()); } + @Test + void testGetMultipleConfigDirs(@TempDir Path extraConfig) throws Exception { + String firstConfig = PROJECT_BASE + "/config"; + ServerCommand cmd = ServerCommand.parse(ServerCommand.class, "-b", PROJECT_BASE, "-m", "standalone", + "-c", firstConfig + "," + extraConfig); + Path pathFirstConfig = Path.of(firstConfig); + assertEquals(List.of(pathFirstConfig, extraConfig), cmd.getConfigDirs()); + // first/primary dir is still returned by getConfig() + assertEquals(pathFirstConfig, cmd.getConfig()); + // property is propagated as the comma-joined absolute paths + assertEquals(pathFirstConfig.toAbsolutePath() + "," + extraConfig.toAbsolutePath(), + System.getProperty(ConfigUtil.CONFIG_DIR_PROPERTY)); + } + + @Test + void testNonExistentConfigDirInListRejected() { + String firstConfig = PROJECT_BASE + "/config"; + assertThrows(Exception.class, + () -> ServerCommand.parse(ServerCommand.class, "-b", PROJECT_BASE, "-m", "standalone", + "-c", firstConfig + ",/path/does/not/exist")); + } + @Test void testModeAddedToFlavors() throws Exception { ServerCommand cmd = ServerCommand.parse(ServerCommand.class, "-b ", PROJECT_BASE_SLASH + "/", "-m", "standalone"); diff --git a/src/test/java/emissary/server/api/EmissaryApiTest.java b/src/test/java/emissary/server/api/EmissaryApiTest.java index c2f7d82565..3319bac389 100644 --- a/src/test/java/emissary/server/api/EmissaryApiTest.java +++ b/src/test/java/emissary/server/api/EmissaryApiTest.java @@ -63,6 +63,7 @@ public void setup() { when(mockServer.getNode()).thenReturn(node); when(mockServer.getServerCommand()).thenReturn(srvCmd); when(srvCmd.getConfig()).thenReturn(Path.of("/path/to/project/config")); + when(srvCmd.getConfigDirsProperty()).thenReturn("/path/to/project/config"); when(srvCmd.getProjectBase()).thenReturn(Path.of("/path/to/project")); when(srvCmd.getOutputDir()).thenReturn(Path.of("/path/to/project/output")); when(srvCmd.getBinDir()).thenReturn(Path.of("/path/to/project/bin")); From 9d409feae6cdd692597db622bd2c35adabec2f54 Mon Sep 17 00:00:00 2001 From: Connor Date: Mon, 22 Jun 2026 16:22:51 -0400 Subject: [PATCH 2/2] cleanup --- .../command/converter/PathExistsConverter.java | 10 +++++++++- .../java/emissary/server/EmissaryServer.java | 4 +++- .../java/emissary/command/ServerCommandIT.java | 17 +++++++++++++++++ .../converter/PathExistsConverterTest.java | 15 +++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/main/java/emissary/command/converter/PathExistsConverter.java b/src/main/java/emissary/command/converter/PathExistsConverter.java index 01e224654c..ce649ea21e 100644 --- a/src/main/java/emissary/command/converter/PathExistsConverter.java +++ b/src/main/java/emissary/command/converter/PathExistsConverter.java @@ -28,7 +28,15 @@ public Path convert(String value) { } public Path convert(String option, String value) { - Path p = Path.of(Strings.CS.removeEnd(value, "/")); + String trimmed = value == null ? "" : value.trim(); + // reject blank entries (e.g. an empty token from a comma-separated list), which would otherwise resolve to the + // current working directory via Path.of("") + if (trimmed.isEmpty()) { + String msg = String.format("The option '%s' was configured with a blank path", option); + LOG.error(msg); + throw new IllegalArgumentException(msg); + } + Path p = Path.of(Strings.CS.removeEnd(trimmed, "/")); // ensure the value exists if (!Files.exists(p)) { String msg = String.format("The option '%s' was configured with path '%s' which does not exist", option, p); diff --git a/src/main/java/emissary/server/EmissaryServer.java b/src/main/java/emissary/server/EmissaryServer.java index fc5c1ecf74..e850382e97 100644 --- a/src/main/java/emissary/server/EmissaryServer.java +++ b/src/main/java/emissary/server/EmissaryServer.java @@ -726,7 +726,9 @@ protected void csrfFilter(ResourceConfig application) { private ContextHandler buildEmissaryHandler() throws EmissaryException { // must set these set or you are not an EmissaryNode String configDir = System.getProperty(ConfigUtil.CONFIG_DIR_PROPERTY, null); - if (configDir == null || Arrays.stream(configDir.split(",")).anyMatch(d -> d.isEmpty() || !Files.exists(Path.of(d)))) { + // split with -1 limit so trailing empty tokens are kept and rejected; trim each entry to tolerate whitespace + if (configDir == null + || Arrays.stream(configDir.split(",", -1)).map(String::trim).anyMatch(d -> d.isEmpty() || !Files.exists(Path.of(d)))) { throw new EmissaryException("Config dir error. " + ConfigUtil.CONFIG_DIR_PROPERTY + " is " + configDir); } // set number of agents if it has been set diff --git a/src/test/java/emissary/command/ServerCommandIT.java b/src/test/java/emissary/command/ServerCommandIT.java index 854bb885d9..72fb2a1c35 100644 --- a/src/test/java/emissary/command/ServerCommandIT.java +++ b/src/test/java/emissary/command/ServerCommandIT.java @@ -50,6 +50,23 @@ void testNonExistentConfigDirInListRejected() { "-c", firstConfig + ",/path/does/not/exist")); } + @Test + void testBlankConfigDirInListRejected() { + String firstConfig = PROJECT_BASE + "/config"; + // an empty token (a,,b) must not silently resolve to the current working directory + assertThrows(Exception.class, + () -> ServerCommand.parse(ServerCommand.class, "-b", PROJECT_BASE, "-m", "standalone", + "-c", firstConfig + ",," + firstConfig)); + } + + @Test + void testConfigDirWhitespaceTrimmed(@TempDir Path extraConfig) throws Exception { + String firstConfig = PROJECT_BASE + "/config"; + ServerCommand cmd = ServerCommand.parse(ServerCommand.class, "-b", PROJECT_BASE, "-m", "standalone", + "-c", firstConfig + " , " + extraConfig); + assertEquals(List.of(Path.of(firstConfig), extraConfig), cmd.getConfigDirs()); + } + @Test void testModeAddedToFlavors() throws Exception { ServerCommand cmd = ServerCommand.parse(ServerCommand.class, "-b ", PROJECT_BASE_SLASH + "/", "-m", "standalone"); diff --git a/src/test/java/emissary/command/converter/PathExistsConverterTest.java b/src/test/java/emissary/command/converter/PathExistsConverterTest.java index 2cd4a37c84..401e30020e 100644 --- a/src/test/java/emissary/command/converter/PathExistsConverterTest.java +++ b/src/test/java/emissary/command/converter/PathExistsConverterTest.java @@ -11,6 +11,7 @@ import java.nio.file.Files; import java.nio.file.Path; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -54,4 +55,18 @@ void convertFailed() { assertThrows(IllegalArgumentException.class, () -> converter.convert("hello")); } + @Test + void blankRejected() { + assertThrows(IllegalArgumentException.class, () -> converter.convert("")); + assertThrows(IllegalArgumentException.class, () -> converter.convert(" ")); + } + + @Test + void trimsWhitespace() { + // leading/trailing whitespace is trimmed before resolving the path + Path result = converter.convert(" " + path + " "); + + assertEquals(path, result); + } + }