From e9e614a4644288148abe6cb6fae3112e25a86d2d Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Tue, 06 Oct 2026 06:58:49 +0000
Subject: [PATCH] [#1159] Keep administrator-typed DNs intact in dsconfig batch lines, tools.properties and the SDK LDAPUrl (#1166)

---
 opendj-config/src/test/java/org/forgerock/opendj/config/dsconfig/DSConfigParseTest.java |  138 ++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 138 insertions(+), 0 deletions(-)

diff --git a/opendj-config/src/test/java/org/forgerock/opendj/config/dsconfig/DSConfigParseTest.java b/opendj-config/src/test/java/org/forgerock/opendj/config/dsconfig/DSConfigParseTest.java
index 6cd877f..7e8e51f 100644
--- a/opendj-config/src/test/java/org/forgerock/opendj/config/dsconfig/DSConfigParseTest.java
+++ b/opendj-config/src/test/java/org/forgerock/opendj/config/dsconfig/DSConfigParseTest.java
@@ -12,15 +12,26 @@
  * information: "Portions Copyright [year] [name of copyright owner]".
  *
  * Portions copyright 2016 ForgeRock AS.
+ * Portions Copyright 2026 3A Systems, LLC.
  */
 package org.forgerock.opendj.config.dsconfig;
 
+import com.forgerock.opendj.cli.ArgumentException;
+import com.forgerock.opendj.cli.ReturnCode;
+
 import org.forgerock.testng.ForgeRockTestCase;
 import org.testng.Assert;
 import org.testng.annotations.DataProvider;
 import org.testng.annotations.Test;
 
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
+import java.util.List;
 
 @Test(groups = { "precommit", "config" })
 public class DSConfigParseTest extends ForgeRockTestCase {
@@ -45,4 +56,131 @@
         Collection<String> cmdLine = DSConfig.toCommandArgs(arg);
         Assert.assertEquals(cmdLine.iterator().next(), value);
     }
+
+    /** Batch lines and the arguments a POSIX shell splits them into. */
+    @DataProvider
+    public Object[][] shellQuoting() {
+        return new Object[][] {
+            // Inside double quotes a backslash is kept unless it precedes ", \, $ or `.
+            { "--set base-dn:\"cn=a\\,b,dc=x\"", args("--set", "base-dn:cn=a\\,b,dc=x") },
+            { "--set \"base-dn:cn=a\\2Cb,dc=x\"", args("--set", "base-dn:cn=a\\2Cb,dc=x") },
+            { "\"a\\\"b\\\\c\\$d\\`e\"", args("a\"b\\c$d`e") },
+            // Single quotes keep everything literally, a backslash included.
+            { "--set 'base-dn:o=My Company'", args("--set", "base-dn:o=My Company") },
+            { "--set 'base-dn:cn=a\\,b,dc=x'", args("--set", "base-dn:cn=a\\,b,dc=x") },
+            { "'say \"hi\"' \"it's\"", args("say \"hi\"", "it's") },
+            // Quoted and unquoted parts of one word make a single argument.
+            { "base-dn:\"o=My \"'Company'", args("base-dn:o=My Company") },
+            { "\"a\"b c", args("ab", "c") },
+            // An empty quoted word is an empty argument.
+            { "--set description:\"\" \"\"", args("--set", "description:", "") },
+            // Tabs separate arguments as spaces do.
+            { "a\tb  c", args("a", "b", "c") },
+            // A trailing backslash has nothing to escape and is kept.
+            { "a b\\", args("a", "b\\") },
+        };
+    }
+
+    @Test(dataProvider = "shellQuoting")
+    public void testShellQuotingInCommandLine(String line, List<String> expected) throws Exception {
+        Assert.assertEquals(new ArrayList<>(DSConfig.toCommandArgs(line)), expected);
+    }
+
+    /** Batch lines with a quote that is not closed: a POSIX shell rejects them. */
+    @DataProvider
+    public Object[][] unclosedQuotes() {
+        return new Object[][] {
+            // Before single quotes were supported, this line gave three arguments.
+            { "--set description:O'Brien --advanced" },
+            { "--set 'base-dn:o=My Company" },
+            { "--set \"base-dn:o=My Company" },
+            { "--set \"base-dn:o=My Company\\\"" },
+        };
+    }
+
+    @Test(dataProvider = "unclosedQuotes", expectedExceptions = ArgumentException.class,
+            expectedExceptionsMessageRegExp = "The following batch command has a quote that is not closed: .*")
+    public void testUnclosedQuoteIsRejected(String line) throws Exception {
+        DSConfig.toCommandArgs(line);
+    }
+
+    @Test
+    public void testBatchCommandWithUnclosedQuoteFails() {
+        final ByteArrayOutputStream err = new ByteArrayOutputStream();
+
+        final int exitCode = DSConfig.runBatchCommand(args("--noPropertiesFile", "-n"), "set-x --set 'a",
+                new PrintStream(new ByteArrayOutputStream()), new PrintStream(err));
+
+        Assert.assertEquals(exitCode, ReturnCode.ERROR_USER_DATA.get());
+        Assert.assertTrue(text(err).contains("The following batch command has a quote that is not closed: "
+                + "set-x --set 'a"), text(err));
+    }
+
+    @Test
+    public void testBatchCommandKeepsEscapedTrailingBlank() {
+        // dsconfig fails on the first argument, before it reads --no-prompt, so it still writes
+        // the error to the output stream.
+        final ByteArrayOutputStream output = new ByteArrayOutputStream();
+        final PrintStream stream = new PrintStream(output);
+
+        // dsconfig has no such subcommand: the error quotes the argument it was given.
+        final int exitCode = DSConfig.runBatchCommand(args("--noPropertiesFile", "-n"), "set-x\\ ", stream, stream);
+
+        Assert.assertEquals(exitCode, ReturnCode.CONFLICTING_ARGS.get());
+        Assert.assertTrue(text(output).contains("The provided argument \"set-x \" is not recognized"), text(output));
+    }
+
+    /** The output with every run of blanks and line breaks as a single space, as the console wraps lines. */
+    private static String text(ByteArrayOutputStream out) {
+        return new String(out.toByteArray()).replaceAll("\\s+", " ");
+    }
+
+    /** Batch files and the arguments of each command they hold. */
+    @DataProvider
+    public Object[][] batchFiles() {
+        return new Object[][] {
+            { "# comment\n\nset-x --foo bar\nset-y\n", commands(args("set-x", "--foo", "bar"), args("set-y")) },
+            // A line of blanks is skipped as an empty line is.
+            { "set-x\n   \nset-y\n", commands(args("set-x"), args("set-y")) },
+            { "set-x\n \t\n", commands(args("set-x")) },
+            // Empty lines and comments are skipped inside a continued command too, so a line of a
+            // long command can be commented out.
+            { "set-x \\\n#  --foo bar \\\n  --baz qux\n", commands(args("set-x", "--baz", "qux")) },
+            { "set-x \\\n\n  --baz qux\n", commands(args("set-x", "--baz", "qux")) },
+            // A line that ends in a backslash continues on the next line.
+            { "set-x \\\n  --foo bar\n", commands(args("set-x", "--foo", "bar")) },
+            { "set-x --foo ab\\\ncd\n", commands(args("set-x", "--foo", "abcd")) },
+            // An escaped backslash at the end of a line does not continue it, as dsconfig
+            // --commandFilePath writes a value that ends in a backslash on UNIX.
+            { "set-x --bindPassword pa\\\\\nset-y --foo bar\n",
+                commands(args("set-x", "--bindPassword", "pa\\"), args("set-y", "--foo", "bar")) },
+            { "set-x --foo a\\\\\\\nb\n", commands(args("set-x", "--foo", "a\\b")) },
+            // An escaped blank at the end of a line is kept.
+            { "set-x --set description:value\\ \n", commands(args("set-x", "--set", "description:value ")) },
+            // A command still runs when its last line continues at the end of the file.
+            { "set-x --foo bar \\", commands(args("set-x", "--foo", "bar")) },
+            { "set-x\n \\\n", commands(args("set-x")) },
+        };
+    }
+
+    @Test(dataProvider = "batchFiles")
+    public void testBatchFileCommands(String batch, List<List<String>> expected) throws Exception {
+        final List<List<String>> actual = new ArrayList<>();
+        try (BufferedReader reader = new BufferedReader(new StringReader(batch))) {
+            String command;
+            while ((command = DSConfig.nextBatchCommand(reader)) != null) {
+                actual.add(new ArrayList<>(DSConfig.toCommandArgs(command)));
+            }
+        }
+        Assert.assertEquals(actual, expected);
+    }
+
+    private static List<String> args(String... args) {
+        return Arrays.asList(args);
+    }
+
+    @SafeVarargs
+    private static List<List<String>> commands(List<String>... commands) {
+        return Arrays.asList(commands);
+    }
 }

--
Gitblit v1.10.0