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/main/java/org/forgerock/opendj/config/dsconfig/DSConfig.java |  157 ++++++++++++++++++++++++++++++++++------------------
 1 files changed, 102 insertions(+), 55 deletions(-)

diff --git a/opendj-config/src/main/java/org/forgerock/opendj/config/dsconfig/DSConfig.java b/opendj-config/src/main/java/org/forgerock/opendj/config/dsconfig/DSConfig.java
index 07d83f1..27b049f 100644
--- a/opendj-config/src/main/java/org/forgerock/opendj/config/dsconfig/DSConfig.java
+++ b/opendj-config/src/main/java/org/forgerock/opendj/config/dsconfig/DSConfig.java
@@ -1394,40 +1394,38 @@
         try (BufferedReader bReader = batchCommandsReader()) {
             List<String> initialArgs = removeBatchArgs(args);
 
-            // Split the CLI string into arguments array
-            String command = "";
-            String line;
-            while ((line = bReader.readLine()) != null) {
-                if (line.isEmpty() || line.startsWith("#")) {
-                    // Empty line or comment
-                    continue;
-                }
-                // command split in several line support
-                if (line.endsWith("\\")) {
-                    // command is split into several lines
-                    command += line.substring(0, line.length() - 1);
-                    continue;
-                }
+            String command;
+            while ((command = nextBatchCommand(bReader)) != null) {
+                // Only the echo is trimmed: an escaped blank at the end of the command is kept.
+                printlnNoWrap(LocalizableMessage.raw(command.trim()));
 
-                command += line;
-                command = command.trim();
-                printlnNoWrap(LocalizableMessage.raw(command));
-
-                // Append initial arguments to the file line
-                final String[] allArgsArray = buildCommandArgs(initialArgs, command);
-                int exitCode = main(allArgsArray, getOutputStream(), getErrorStream());
+                final int exitCode = runBatchCommand(initialArgs, command, getOutputStream(), getErrorStream());
                 if (exitCode != ReturnCode.SUCCESS.get()) {
                     System.exit(filterExitCode(exitCode));
                 }
                 println();
-                // reset command
-                command = "";
             }
         } catch (IOException ex) {
             errPrintln(ERR_DSCFG_ERROR_READING_BATCH_FILE.get(ex));
         }
     }
 
+    /**
+     * Runs one command of a batch with the initial arguments appended, and returns its exit code.
+     * A command that cannot be split into arguments is reported on the error stream: a batch
+     * always runs with --no-prompt, so that is where dsconfig writes its errors.
+     */
+    static int runBatchCommand(final List<String> initialArgs, final String command, final PrintStream out,
+            final PrintStream err) {
+        try {
+            // Append initial arguments to the file line
+            return main(buildCommandArgs(initialArgs, command), out, err);
+        } catch (final ArgumentException e) {
+            err.println(wrapText(e.getMessageObject(), MAX_LINE_WIDTH));
+            return ReturnCode.ERROR_USER_DATA.get();
+        }
+    }
+
     private BufferedReader batchCommandsReader() throws FileNotFoundException {
         if (batchArgument.isPresent()) {
             return new BufferedReader(new InputStreamReader(System.in));
@@ -1440,7 +1438,38 @@
         }
     }
 
-    private String[] buildCommandArgs(List<String> initialArgs, String batchCommand) {
+    /**
+     * Reads the next command of a batch, or returns {@code null} at its end. Lines that are empty
+     * or hold only blanks, and comments, are skipped, also inside a command that continues over
+     * several lines, and a line that ends in an unescaped backslash continues on the next line. A
+     * command whose last line continues still runs at the end of the batch.
+     */
+    static String nextBatchCommand(final BufferedReader reader) throws IOException {
+        final StringBuilder command = new StringBuilder();
+        String line;
+        while ((line = reader.readLine()) != null) {
+            if (line.trim().isEmpty() || line.startsWith("#")) {
+                // Empty or blank line, or comment
+                continue;
+            }
+            if (!continuesOnNextLine(line)) {
+                return command.append(line).toString();
+            }
+            command.append(line, 0, line.length() - 1);
+        }
+        return command.toString().trim().isEmpty() ? null : command.toString();
+    }
+
+    /** Whether a line ends in a backslash that is not itself escaped by a backslash. */
+    private static boolean continuesOnNextLine(final String line) {
+        int backslashes = 0;
+        for (int i = line.length() - 1; i >= 0 && line.charAt(i) == '\\'; i--) {
+            backslashes++;
+        }
+        return backslashes % 2 == 1;
+    }
+
+    private static String[] buildCommandArgs(List<String> initialArgs, String batchCommand) throws ArgumentException {
         final Collection<String> commandArgs = toCommandArgs(batchCommand);
         final int length = commandArgs.size() + initialArgs.size();
         final List<String> allArguments = new ArrayList<>(length);
@@ -1449,48 +1478,66 @@
         return allArguments.toArray(new String[length]);
     }
 
-    static Collection<String> toCommandArgs(String command) {
-        Collection<String> commandArgs = new ArrayList<>();
-        StringBuilder builder = new StringBuilder();
-        boolean inQuotes = false;
+    /**
+     * Splits a batch line into arguments the way a POSIX shell does, without any expansion: a
+     * backslash outside quotes escapes the next char, single quotes keep everything literally, and
+     * inside double quotes a backslash escapes only {@code "}, {@code \}, {@code $} and {@code `}.
+     *
+     * @throws ArgumentException
+     *             If a quote is not closed.
+     */
+    static Collection<String> toCommandArgs(String command) throws ArgumentException {
+        final Collection<String> commandArgs = new ArrayList<>();
+        final StringBuilder builder = new StringBuilder();
+        // A word that holds only quotes is still an (empty) argument.
+        boolean inWord = false;
         for (int i = 0; i < command.length(); i++) {
             final char c = command.charAt(i);
             switch (c) {
+            case ' ':
+            case '\t':
+                if (inWord) {
+                    commandArgs.add(builder.toString());
+                    builder.setLength(0);
+                    inWord = false;
+                }
+                continue;
+            case '\\':
+                // A trailing backslash has nothing to escape and is kept.
+                builder.append(i + 1 < command.length() ? command.charAt(++i) : c);
+                break;
+            case '\'':
+                final int end = command.indexOf('\'', i + 1);
+                if (end < 0) {
+                    throw new ArgumentException(ERR_DSCFG_ERROR_BATCH_UNCLOSED_QUOTE.get(command.trim()));
+                }
+                builder.append(command, i + 1, end);
+                i = end;
+                break;
+            case '"':
+                for (i++; i < command.length() && command.charAt(i) != '"'; i++) {
+                    if (command.charAt(i) == '\\' && i + 1 < command.length()
+                            && "\"\\$`".indexOf(command.charAt(i + 1)) >= 0) {
+                        i++;
+                    }
+                    builder.append(command.charAt(i));
+                }
+                if (i == command.length()) {
+                    throw new ArgumentException(ERR_DSCFG_ERROR_BATCH_UNCLOSED_QUOTE.get(command.trim()));
+                }
+                break;
             default:
                 builder.append(c);
                 break;
-            case '\\':
-                builder.append(command.charAt(++i));
-                break;
-            case '"':
-                if (inQuotes) {
-                    builder = newArgumentString(commandArgs, builder);
-                    inQuotes = false;
-                } else {
-                    inQuotes = true;
-                }
-                break;
-            case ' ':
-                if (inQuotes) {
-                    builder.append(c);
-                } else {
-                    builder = newArgumentString(commandArgs, builder);
-                }
-                break;
             }
+            inWord = true;
         }
-        newArgumentString(commandArgs, builder);
+        if (inWord) {
+            commandArgs.add(builder.toString());
+        }
         return commandArgs;
     }
 
-    private static StringBuilder newArgumentString(Collection<String> commandArgs, StringBuilder stringBuilder) {
-        if (stringBuilder.length() > 0) {
-            commandArgs.add(stringBuilder.toString());
-            stringBuilder = new StringBuilder();
-        }
-        return stringBuilder;
-    }
-
     private List<String> removeBatchArgs(String[] args) {
         // Build a list of initial arguments,
         // removing the batch file option + its value

--
Gitblit v1.10.0