View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   https://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  
20  package org.apache.commons.exec;
21  
22  import java.io.File;
23  import java.nio.file.Path;
24  import java.util.ArrayList;
25  import java.util.HashMap;
26  import java.util.List;
27  import java.util.Map;
28  import java.util.Objects;
29  import java.util.StringTokenizer;
30  
31  import org.apache.commons.exec.util.StringUtils;
32  
33  /**
34   * CommandLine objects help handling command lines specifying processes to execute. The class can be used to a command line by an application.
35   */
36  public class CommandLine {
37  
38      /**
39       * Encapsulates a command line argument.
40       */
41      static final class Argument {
42  
43          /**
44           * Argument value.
45           */
46          private final String value;
47  
48          /**
49           * Whether to quote arguments.
50           */
51          private final boolean handleQuoting;
52  
53          private Argument(final String value, final boolean handleQuoting) {
54              this.value = value.trim();
55              this.handleQuoting = handleQuoting;
56          }
57  
58          private String getValue() {
59              return value;
60          }
61  
62          private boolean isHandleQuoting() {
63              return handleQuoting;
64          }
65      }
66  
67      /**
68       * Create a command line from a string.
69       *
70       * @param line the first element becomes the executable, the rest the arguments.
71       * @return the parsed command line.
72       * @throws IllegalArgumentException If line is null or all whitespace.
73       */
74      public static CommandLine parse(final String line) {
75          return parse(line, null);
76      }
77  
78      /**
79       * Create a command line from a string.
80       *
81       * @param line            the first element becomes the executable, the rest the arguments.
82       * @param substitutionMap the name/value pairs used for substitution.
83       * @return the parsed command line.
84       * @throws IllegalArgumentException If line is null or all whitespace.
85       */
86      public static CommandLine parse(final String line, final Map<String, ?> substitutionMap) {
87          if (line == null) {
88              throw new IllegalArgumentException("Command line cannot be null");
89          }
90          if (line.trim().isEmpty()) {
91              throw new IllegalArgumentException("Command line cannot be empty");
92          }
93          final String[] tmp = translateCommandline(line);
94          final CommandLine cl = new CommandLine(tmp[0]);
95          cl.setSubstitutionMap(substitutionMap);
96          for (int i = 1; i < tmp.length; i++) {
97              cl.addArgument(tmp[i]);
98          }
99          return cl;
100     }
101 
102     /**
103      * Crack a command line.
104      *
105      * @param toProcess the command line to process.
106      * @return the command line broken into strings. An empty or null toProcess parameter results in a zero sized array.
107      */
108     private static String[] translateCommandline(final String toProcess) {
109         if (toProcess == null || toProcess.trim().isEmpty()) {
110             // no command? no string
111             return new String[0];
112         }
113         // parse with a simple finite state machine.
114         final int normal = 0;
115         final int inQuote = 1;
116         final int inDoubleQuote = 2;
117         int state = normal;
118         final StringTokenizer tok = new StringTokenizer(toProcess, "\"\' ", true);
119         final ArrayList<String> list = new ArrayList<>();
120         StringBuilder current = new StringBuilder();
121         boolean lastTokenHasBeenQuoted = false;
122         while (tok.hasMoreTokens()) {
123             final String nextTok = tok.nextToken();
124             switch (state) {
125             case inQuote:
126                 if ("\'".equals(nextTok)) {
127                     lastTokenHasBeenQuoted = true;
128                     state = normal;
129                 } else {
130                     current.append(nextTok);
131                 }
132                 break;
133             case inDoubleQuote:
134                 if ("\"".equals(nextTok)) {
135                     lastTokenHasBeenQuoted = true;
136                     state = normal;
137                 } else {
138                     current.append(nextTok);
139                 }
140                 break;
141             default:
142                 switch (nextTok) {
143                 case "\'":
144                     state = inQuote;
145                     break;
146                 case "\"":
147                     state = inDoubleQuote;
148                     break;
149                 case " ":
150                     if (lastTokenHasBeenQuoted || current.length() != 0) {
151                         list.add(current.toString());
152                         current = new StringBuilder();
153                     }
154                     break;
155                 default:
156                     current.append(nextTok);
157                     break;
158                 }
159                 lastTokenHasBeenQuoted = false;
160                 break;
161             }
162         }
163         if (lastTokenHasBeenQuoted || current.length() != 0) {
164             list.add(current.toString());
165         }
166         if (state == inQuote || state == inDoubleQuote) {
167             throw new IllegalArgumentException("Unbalanced quotes in " + toProcess);
168         }
169         final String[] args = new String[list.size()];
170         return list.toArray(args);
171     }
172 
173     /**
174      * The arguments of the command.
175      */
176     private final List<Argument> arguments = new ArrayList<>();
177 
178     /**
179      * The program to execute.
180      */
181     private final String executable;
182 
183     /**
184      * A map of name value pairs used to expand command line arguments.
185      */
186     private Map<String, ?> substitutionMap; // This can contain values other than Strings.
187 
188     /**
189      * Tests whether a file was used to set the executable.
190      */
191     private final boolean isFile;
192 
193     /**
194      * Copy constructor.
195      *
196      * @param other the instance to copy.
197      */
198     public CommandLine(final CommandLine other) {
199         this.executable = other.getExecutable();
200         this.isFile = other.isFile();
201         this.arguments.addAll(other.arguments);
202         if (other.getSubstitutionMap() != null) {
203             this.substitutionMap = new HashMap<>(other.getSubstitutionMap());
204         }
205     }
206 
207     /**
208      * Constructs a command line without any arguments.
209      *
210      * @param executable the executable file.
211      */
212     public CommandLine(final File executable) {
213         this.isFile = true;
214         this.executable = toCleanExecutable(executable.getAbsolutePath());
215     }
216 
217     /**
218      * Constructs a command line without any arguments.
219      *
220      * @param executable the executable file.
221      * @since 1.5.0
222      */
223     public CommandLine(final Path executable) {
224         this.isFile = true;
225         this.executable = toCleanExecutable(executable.toAbsolutePath().toString());
226     }
227 
228     /**
229      * Constructs a command line without any arguments.
230      *
231      * @param executable the executable.
232      * @throws NullPointerException     on null input.
233      * @throws IllegalArgumentException on empty input.
234      */
235     public CommandLine(final String executable) {
236         this.isFile = false;
237         this.executable = toCleanExecutable(executable);
238     }
239 
240     /**
241      * Add a single argument. Handles quoting.
242      *
243      * @param argument The argument to add.
244      * @return The command line itself.
245      * @throws IllegalArgumentException If argument contains both single and double quotes.
246      */
247     public CommandLine addArgument(final String argument) {
248         return addArgument(argument, true);
249     }
250 
251     /**
252      * Add a single argument.
253      *
254      * @param argument      The argument to add.
255      * @param handleQuoting Add the argument with/without handling quoting.
256      * @return The command line itself.
257      */
258     public CommandLine addArgument(final String argument, final boolean handleQuoting) {
259         if (argument == null) {
260             return this;
261         }
262         // check if we can really quote the argument - if not throw an
263         // IllegalArgumentException
264         if (handleQuoting) {
265             StringUtils.quoteArgument(argument);
266         }
267         arguments.add(new Argument(argument, handleQuoting));
268         return this;
269     }
270 
271     /**
272      * Add multiple arguments. Handles parsing of quotes and whitespace. Please note that the parsing can have undesired side-effects therefore it is
273      * recommended to build the command line incrementally.
274      *
275      * @param addArguments A string containing multiple arguments.
276      * @return The command line itself.
277      */
278     public CommandLine addArguments(final String addArguments) {
279         return addArguments(addArguments, true);
280     }
281 
282     /**
283      * Add multiple arguments. Handles parsing of quotes and whitespace. Please note that the parsing can have undesired side-effects therefore it is
284      * recommended to build the command line incrementally.
285      *
286      * @param addArguments  A string containing multiple arguments.
287      * @param handleQuoting Add the argument with/without handling quoting.
288      * @return The command line itself.
289      */
290     public CommandLine addArguments(final String addArguments, final boolean handleQuoting) {
291         if (addArguments != null) {
292             final String[] argumentsArray = translateCommandline(addArguments);
293             addArguments(argumentsArray, handleQuoting);
294         }
295         return this;
296     }
297 
298     /**
299      * Add multiple arguments. Handles parsing of quotes and whitespace.
300      *
301      * @param addArguments An array of arguments.
302      * @return The command line itself.
303      */
304     public CommandLine addArguments(final String[] addArguments) {
305         return addArguments(addArguments, true);
306     }
307 
308     /**
309      * Add multiple arguments.
310      *
311      * @param addArguments  An array of arguments.
312      * @param handleQuoting Add the argument with/without handling quoting.
313      * @return The command line itself.
314      */
315     public CommandLine addArguments(final String[] addArguments, final boolean handleQuoting) {
316         if (addArguments != null) {
317             for (final String addArgument : addArguments) {
318                 addArgument(addArgument, handleQuoting);
319             }
320         }
321         return this;
322     }
323 
324     /**
325      * Expand variables in a command line argument.
326      *
327      * @param argument the argument.
328      * @return the expanded string.
329      */
330     private String expandArgument(final String argument) {
331         final StringBuffer stringBuffer = StringUtils.stringSubstitution(argument, getSubstitutionMap(), true);
332         return stringBuffer.toString();
333     }
334 
335     /**
336      * Gets the expanded and quoted command line arguments.
337      *
338      * @return The quoted arguments.
339      */
340     public String[] getArguments() {
341         Argument currArgument;
342         String expandedArgument;
343         final String[] result = new String[arguments.size()];
344         for (int i = 0; i < result.length; i++) {
345             currArgument = arguments.get(i);
346             expandedArgument = expandArgument(currArgument.getValue());
347             result[i] = currArgument.isHandleQuoting() ? StringUtils.quoteArgument(expandedArgument) : expandedArgument;
348         }
349         return result;
350     }
351 
352     /**
353      * Gets the executable.
354      *
355      * @return The executable.
356      */
357     public String getExecutable() {
358         // Expand the executable and replace '/' and '\\' with the platform
359         // specific file separator char. This is safe here since we know
360         // that this is a platform specific command.
361         return StringUtils.fixFileSeparatorChar(expandArgument(executable));
362     }
363 
364     /**
365      * Gets the substitution map.
366      *
367      * @return the substitution map.
368      */
369     public Map<String, ?> getSubstitutionMap() {
370         return substitutionMap;
371     }
372 
373     /**
374      * Tests whether a file was used to set the executable.
375      *
376      * @return true whether a file was used for setting the executable.
377      */
378     public boolean isFile() {
379         return isFile;
380     }
381 
382     /**
383      * Sets the substitutionMap to expand variables in the command line.
384      *
385      * @param substitutionMap the map
386      */
387     public void setSubstitutionMap(final Map<String, ?> substitutionMap) {
388         this.substitutionMap = substitutionMap;
389     }
390 
391     /**
392      * Cleans the executable string. The argument is trimmed and '/' and '\\' are replaced with the platform specific file separator char
393      *
394      * @param dirtyExecutable the executable.
395      * @return the platform-specific executable string.
396      * @throws NullPointerException     on null input.
397      * @throws IllegalArgumentException on empty input.
398      */
399     private String toCleanExecutable(final String dirtyExecutable) {
400         Objects.requireNonNull(dirtyExecutable, "dirtyExecutable");
401         if (dirtyExecutable.trim().isEmpty()) {
402             throw new IllegalArgumentException("Executable cannot be empty");
403         }
404         return StringUtils.fixFileSeparatorChar(dirtyExecutable);
405     }
406 
407     /**
408      * Stringify operator returns the command line as a string.
409      * Parameters are correctly quoted when containing a space or left untouched if they are already
410      * quoted.
411      *
412      * @return the command line as single string.
413      */
414     @Override
415     public String toString() {
416         return "[" + String.join(", ", toStrings()) + "]";
417     }
418 
419     /**
420      * Converts the command line as an array of strings.
421      *
422      * @return The command line as a string array.
423      */
424     public String[] toStrings() {
425         final String[] result = new String[arguments.size() + 1];
426         result[0] = getExecutable();
427         System.arraycopy(getArguments(), 0, result, 1, result.length - 1);
428         return result;
429     }
430 }